diff --git a/frontend/src/components/pagination.tsx b/frontend/src/components/pagination.tsx new file mode 100644 index 0000000..3ff2a9b --- /dev/null +++ b/frontend/src/components/pagination.tsx @@ -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 +} + +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 ( +
+ + ← Previous + + +
+ {pages.map((p, idx) => ( +
+ {idx > 0 && p - 1 !== pages[idx - 1] && } + + {p} + +
+ ))} +
+ + + + + Next → + +
+ ) +} diff --git a/frontend/src/hooks/use-debounced-callback.ts b/frontend/src/hooks/use-debounced-callback.ts new file mode 100644 index 0000000..b4dd79c --- /dev/null +++ b/frontend/src/hooks/use-debounced-callback.ts @@ -0,0 +1,15 @@ +import { useEffect, useMemo, useRef } from 'react' + +export function useDebouncedCallback void>(fn: T, delay: number): T { + const ref = useRef(fn) + useEffect(() => { + ref.current = fn + }) + return useMemo(() => { + let timer: ReturnType | undefined + return ((...args: Parameters) => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => ref.current(...args), delay) + }) as T + }, [delay]) +} diff --git a/frontend/src/hooks/use-local-storage.ts b/frontend/src/hooks/use-local-storage.ts new file mode 100644 index 0000000..1283616 --- /dev/null +++ b/frontend/src/hooks/use-local-storage.ts @@ -0,0 +1,24 @@ +import { useCallback, useEffect, useState } from 'react' + +export function useLocalStorage(key: string, initialValue: T): [T, (v: T) => void] { + const [value, setValue] = useState(() => { + 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] +} diff --git a/frontend/src/routes/vendors/$slug.tsx b/frontend/src/routes/vendors/$slug.tsx new file mode 100644 index 0000000..09bc445 --- /dev/null +++ b/frontend/src/routes/vendors/$slug.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/vendors/$slug')({ + component: () => null, +}) diff --git a/frontend/src/routes/vendors/index.tsx b/frontend/src/routes/vendors/index.tsx new file mode 100644 index 0000000..8a147a9 --- /dev/null +++ b/frontend/src/routes/vendors/index.tsx @@ -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): 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('perPage', 12) + const [layout, setLayout] = useLocalStorage('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 ( +
+ + Vendors + + +
+

Vendors

+
+ {isFetching && } +
+
+
+ + { + const v = e.target.value.toLowerCase() + setSearchInput(v) + pushSearch(v) + }} + /> + {searchInput && ( + + )} +
+ + + + + + + {categories.map((cat) => ( + + navigate({ search: (s) => ({ ...s, category: cat.slug, page: undefined }) }) + } + > + {category === cat.slug && } + {cat.name} + + ))} + + navigate({ search: (s) => ({ ...s, category: undefined, page: undefined }) })} + > + Clear + + + + +
+ + +
+
+
+ + {error && ( + + There was an error processing your request + + )} + + {layout === 'GRID' ? ( +
+ {isFetching && !response + ? Array.from({ length: perPage }).map((_, k) => ( + + + + + + + + + )) + : vendors.map((v) => ( + + {v.name} + + + {v.name} + +

{truncateDescription(v.description)}

+
+
+ ))} +
+ ) : ( +
+ + + {isFetching && !response + ? Array.from({ length: perPage }).map((_, k) => ( + + + + + + )) + : vendors.map((v) => ( + + + + + + ))} + +
+ + + + + +
+ {v.name} + + + {v.name} + + + {truncateDescription(v.description)} +
+
+ )} + + {isEmpty && ( + +
+ + No results found. + + Try refining your search term and filters … + +
+
+ )} + + {filterCount > perPage && ( + <> + ({ + q: q || undefined, + category: category || undefined, + page: p, + })} + /> +
+ Results per page: +
+ {[12, 24, 48].map((n, i) => ( + + ))} +
+
+ + )} +
+ ) +}