Add vendors listing with search, category filter, grid/list, pagination
This commit is contained in:
81
frontend/src/components/pagination.tsx
Normal file
81
frontend/src/components/pagination.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
type Props = {
|
||||
page: number
|
||||
itemsPerPage: number
|
||||
totalItems: number
|
||||
limit?: number
|
||||
buildSearch: (nextPage: number) => Record<string, unknown>
|
||||
}
|
||||
|
||||
export function Pagination({ page, itemsPerPage, totalItems, limit = 3, buildSearch }: Props) {
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
|
||||
if (totalPages === 1) return null
|
||||
|
||||
const pages: number[] = []
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
if (i < limit || i >= totalPages - limit || (i >= page - limit && i < page + limit - 1)) {
|
||||
pages.push(i + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const prev = Math.max(1, page - 1)
|
||||
const next = Math.min(totalPages, page + 1)
|
||||
|
||||
return (
|
||||
<div className="my-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<Link
|
||||
to="."
|
||||
search={buildSearch(prev)}
|
||||
className="inline-flex h-10 items-center justify-center rounded-md border px-3 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
← Previous
|
||||
</Link>
|
||||
|
||||
<div className="hidden gap-1 md:flex">
|
||||
{pages.map((p, idx) => (
|
||||
<div key={p} className="flex items-center">
|
||||
{idx > 0 && p - 1 !== pages[idx - 1] && <span className="mx-2 leading-10">…</span>}
|
||||
<Link
|
||||
to="."
|
||||
search={buildSearch(p)}
|
||||
className={cn(
|
||||
'inline-flex h-10 min-w-10 items-center justify-center rounded-md border px-3 text-sm hover:bg-accent hover:text-accent-foreground',
|
||||
p === page && 'bg-accent font-semibold',
|
||||
)}
|
||||
>
|
||||
{p}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="h-10 rounded-md border bg-transparent px-3 text-sm md:hidden"
|
||||
value={page}
|
||||
onChange={(e) => {
|
||||
const target = Number(e.target.value)
|
||||
const hash = Object.entries(buildSearch(target))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('&')
|
||||
window.location.search = `?${hash}`
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Link
|
||||
to="."
|
||||
search={buildSearch(next)}
|
||||
className="inline-flex h-10 items-center justify-center rounded-md border px-3 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Next →
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
frontend/src/hooks/use-debounced-callback.ts
Normal file
15
frontend/src/hooks/use-debounced-callback.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
export function useDebouncedCallback<T extends (...args: never[]) => void>(fn: T, delay: number): T {
|
||||
const ref = useRef(fn)
|
||||
useEffect(() => {
|
||||
ref.current = fn
|
||||
})
|
||||
return useMemo(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
return ((...args: Parameters<T>) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => ref.current(...args), delay)
|
||||
}) as T
|
||||
}, [delay])
|
||||
}
|
||||
24
frontend/src/hooks/use-local-storage.ts
Normal file
24
frontend/src/hooks/use-local-storage.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (v: T) => void] {
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
if (typeof window === 'undefined') return initialValue
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
return raw != null ? (JSON.parse(raw) as T) : initialValue
|
||||
} catch {
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}, [key, value])
|
||||
|
||||
const set = useCallback((v: T) => setValue(v), [])
|
||||
return [value, set]
|
||||
}
|
||||
5
frontend/src/routes/vendors/$slug.tsx
vendored
Normal file
5
frontend/src/routes/vendors/$slug.tsx
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/vendors/$slug')({
|
||||
component: () => null,
|
||||
})
|
||||
316
frontend/src/routes/vendors/index.tsx
vendored
Normal file
316
frontend/src/routes/vendors/index.tsx
vendored
Normal file
@@ -0,0 +1,316 @@
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { Check, Filter, FilterX, LayoutGrid, List, Loader2, Search, SmilePlus, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
import { Pagination } from '~/components/pagination'
|
||||
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Card, CardContent } from '~/components/ui/card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '~/components/ui/dropdown-menu'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import { Skeleton } from '~/components/ui/skeleton'
|
||||
|
||||
import { useDebouncedCallback } from '~/hooks/use-debounced-callback'
|
||||
import { useLocalStorage } from '~/hooks/use-local-storage'
|
||||
import { assetUrl } from '~/lib/directus'
|
||||
import { categoriesQuery, vendorsListQuery } from '~/lib/queries'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
type Search = {
|
||||
page?: number
|
||||
q?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/vendors/')({
|
||||
validateSearch: (search: Record<string, unknown>): Search => ({
|
||||
page: search.page ? Number(search.page) : undefined,
|
||||
q: typeof search.q === 'string' ? search.q : undefined,
|
||||
category: typeof search.category === 'string' ? search.category : undefined,
|
||||
}),
|
||||
loaderDeps: ({ search }) => search,
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
await queryClient.ensureQueryData(categoriesQuery)
|
||||
},
|
||||
component: VendorsPage,
|
||||
})
|
||||
|
||||
type Layout = 'GRID' | 'LIST'
|
||||
|
||||
function truncateDescription(desc: string | null | undefined): string {
|
||||
if (!desc) return ''
|
||||
const head = desc.substring(0, 80)
|
||||
const end = head.lastIndexOf(' ')
|
||||
return desc.substring(0, end > 0 ? end : head.length) + ' …'
|
||||
}
|
||||
|
||||
function VendorsPage() {
|
||||
const { page = 1, q = '', category = '' } = Route.useSearch()
|
||||
const navigate = Route.useNavigate()
|
||||
|
||||
const { data: categories } = useSuspenseQuery(categoriesQuery)
|
||||
|
||||
const [perPage, setPerPage] = useLocalStorage<number>('perPage', 12)
|
||||
const [layout, setLayout] = useLocalStorage<Layout>('layout', 'GRID')
|
||||
|
||||
const [searchInput, setSearchInput] = useState(q)
|
||||
useEffect(() => {
|
||||
setSearchInput(q)
|
||||
}, [q])
|
||||
|
||||
const pushSearch = useDebouncedCallback((value: string) => {
|
||||
navigate({ search: (s) => ({ ...s, q: value || undefined, page: undefined }) })
|
||||
}, 500)
|
||||
|
||||
const {
|
||||
data: response,
|
||||
error,
|
||||
isFetching,
|
||||
} = useQuery(vendorsListQuery({ page, perPage, search: q, category }))
|
||||
|
||||
const vendors = response?.data ?? []
|
||||
const filterCount = response?.meta?.filter_count ?? 0
|
||||
const lastPage = Math.max(1, Math.ceil(filterCount / perPage))
|
||||
|
||||
useEffect(() => {
|
||||
if (response && page > lastPage) {
|
||||
navigate({ search: (s) => ({ ...s, page: lastPage }), replace: true })
|
||||
}
|
||||
}, [response, page, lastPage, navigate])
|
||||
|
||||
const isEmpty = !isFetching && filterCount === 0
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Helmet>
|
||||
<title>Vendors</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="mb-5 flex flex-col items-stretch gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<h1 className="text-2xl font-bold">Vendors</h1>
|
||||
<div className="flex h-9 items-center justify-center">
|
||||
{isFetching && <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9 pr-9"
|
||||
placeholder="Search vendors"
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.toLowerCase()
|
||||
setSearchInput(v)
|
||||
pushSearch(v)
|
||||
}}
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setSearchInput('')
|
||||
pushSearch('')
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" aria-label="Filter by category">
|
||||
{category ? <FilterX /> : <Filter />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{categories.map((cat) => (
|
||||
<DropdownMenuItem
|
||||
key={cat.slug}
|
||||
disabled={category === cat.slug}
|
||||
onSelect={() =>
|
||||
navigate({ search: (s) => ({ ...s, category: cat.slug, page: undefined }) })
|
||||
}
|
||||
>
|
||||
{category === cat.slug && <Check className="h-4 w-4" />}
|
||||
{cat.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => navigate({ search: (s) => ({ ...s, category: undefined, page: undefined }) })}
|
||||
>
|
||||
Clear
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="inline-flex rounded-md border">
|
||||
<Button
|
||||
variant={layout === 'GRID' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="rounded-r-none"
|
||||
onClick={() => setLayout('GRID')}
|
||||
aria-label="Grid layout"
|
||||
disabled={layout === 'GRID'}
|
||||
>
|
||||
<LayoutGrid />
|
||||
</Button>
|
||||
<Button
|
||||
variant={layout === 'LIST' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="rounded-l-none border-l"
|
||||
onClick={() => setLayout('LIST')}
|
||||
aria-label="List layout"
|
||||
disabled={layout === 'LIST'}
|
||||
>
|
||||
<List />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="my-3">
|
||||
<AlertDescription>There was an error processing your request</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{layout === 'GRID' ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{isFetching && !response
|
||||
? Array.from({ length: perPage }).map((_, k) => (
|
||||
<Card key={k}>
|
||||
<Skeleton className="h-40 rounded-none" />
|
||||
<CardContent className="pt-4">
|
||||
<Skeleton className="mb-2 h-6" />
|
||||
<Skeleton className="mb-1 h-4" />
|
||||
<Skeleton className="h-4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
: vendors.map((v) => (
|
||||
<Card key={v.id} className="group relative hover:border-primary/40">
|
||||
<img
|
||||
src={assetUrl(v.logo, 'logo-card')}
|
||||
alt={v.name}
|
||||
width={250}
|
||||
height={150}
|
||||
className="h-40 w-full rounded-t-lg bg-white object-contain"
|
||||
/>
|
||||
<CardContent className="pt-4">
|
||||
<Link
|
||||
to="/vendors/$slug"
|
||||
params={{ slug: v.slug }}
|
||||
className="mb-2 block text-base font-extrabold after:absolute after:inset-0"
|
||||
>
|
||||
{v.name}
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground">{truncateDescription(v.description)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<tbody>
|
||||
{isFetching && !response
|
||||
? Array.from({ length: perPage }).map((_, k) => (
|
||||
<tr key={k} className="border-b">
|
||||
<td className="w-[10%] p-3">
|
||||
<Skeleton className="h-12 w-12" />
|
||||
</td>
|
||||
<td className="w-[30%] p-3">
|
||||
<Skeleton className="h-6" />
|
||||
</td>
|
||||
<td className="w-[60%] p-3">
|
||||
<Skeleton className="h-6" />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
: vendors.map((v) => (
|
||||
<tr key={v.id} className="border-b last:border-0 hover:bg-accent/50">
|
||||
<td className="w-[10%] p-3 align-middle">
|
||||
<img
|
||||
src={assetUrl(v.logo, 'logo-card')}
|
||||
alt={v.name}
|
||||
className="h-12 w-12 rounded-md bg-white object-contain"
|
||||
/>
|
||||
</td>
|
||||
<td className="w-[30%] p-3 align-middle font-medium">
|
||||
<Link to="/vendors/$slug" params={{ slug: v.slug }} className="hover:underline">
|
||||
{v.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="w-[60%] p-3 align-middle text-muted-foreground">
|
||||
{truncateDescription(v.description)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<Alert variant="info" className={cn('my-3 rounded-md py-10')}>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<SmilePlus className="h-8 w-8" />
|
||||
<AlertTitle className="mt-4 text-lg">No results found.</AlertTitle>
|
||||
<AlertDescription className="max-w-sm">
|
||||
Try refining your search term and filters …
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{filterCount > perPage && (
|
||||
<>
|
||||
<Pagination
|
||||
page={page}
|
||||
itemsPerPage={perPage}
|
||||
totalItems={filterCount}
|
||||
buildSearch={(p) => ({
|
||||
q: q || undefined,
|
||||
category: category || undefined,
|
||||
page: p,
|
||||
})}
|
||||
/>
|
||||
<div className="mt-5 flex items-center justify-center gap-2">
|
||||
<span className="text-sm">Results per page:</span>
|
||||
<div className="inline-flex rounded-md border">
|
||||
{[12, 24, 48].map((n, i) => (
|
||||
<Button
|
||||
key={n}
|
||||
variant={perPage === n ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
disabled={perPage === n}
|
||||
onClick={() => setPerPage(n)}
|
||||
className={cn(
|
||||
'rounded-none',
|
||||
i === 0 && 'rounded-l-md',
|
||||
i === 2 && 'rounded-r-md',
|
||||
i > 0 && 'border-l',
|
||||
)}
|
||||
>
|
||||
{n}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user