Compare commits
6 Commits
f5f167549f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 33f52aad6c | |||
| da8a7f17a4 | |||
| 2fe67960a1 | |||
| fc59e7e133 | |||
| 39460df61d | |||
| b481531301 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -11,6 +11,10 @@ node_modules/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# pnpm: only the workspace-root lockfile is authoritative
|
||||
backend/pnpm-lock.yaml
|
||||
frontend/pnpm-lock.yaml
|
||||
|
||||
# Mac
|
||||
.DS_Store
|
||||
|
||||
|
||||
14825
backend/pnpm-lock.yaml
generated
14825
backend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,54 +1,61 @@
|
||||
version: '3'
|
||||
|
||||
networks:
|
||||
main:
|
||||
external: true
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: node:lts-bookworm
|
||||
image: node:22-bookworm
|
||||
container_name: pca_pijac_backend
|
||||
restart: unless-stopped
|
||||
env_file: ./backend/.env.production
|
||||
working_dir: /repo/backend
|
||||
networks:
|
||||
- main
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- .:/repo
|
||||
- ./data:/data
|
||||
labels:
|
||||
caddy: admin.pca-pijac.dev.civokram.com
|
||||
caddy.reverse_proxy: '{{upstreams 8055}}'
|
||||
environment:
|
||||
CI: "true"
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- -ec
|
||||
- |
|
||||
npm i -g pnpm
|
||||
cd /app
|
||||
pnpm install --production
|
||||
cd /repo
|
||||
pnpm install --filter ./backend... --frozen-lockfile --prod
|
||||
cd /repo/backend
|
||||
rm -f .env
|
||||
pnpm exec directus database migrate:latest
|
||||
pnpm run start
|
||||
|
||||
frontend:
|
||||
image: node:lts-bookworm
|
||||
image: node:22-bookworm
|
||||
container_name: pca_pijac_frontend
|
||||
restart: unless-stopped
|
||||
env_file: ./frontend/.env.production
|
||||
working_dir: /repo/frontend
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- main
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- .:/repo
|
||||
labels:
|
||||
caddy: pca-pijac.dev.civokram.com
|
||||
caddy.reverse_proxy: '{{upstreams 3000}}'
|
||||
environment:
|
||||
CI: "true"
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- -ec
|
||||
- |
|
||||
npm i -g pnpm
|
||||
cd /app
|
||||
pnpm install --production
|
||||
cd /repo
|
||||
pnpm install --filter ./frontend... --frozen-lockfile
|
||||
cd /repo/frontend
|
||||
sleep 10
|
||||
rm -f .env
|
||||
pnpm run build
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "vite build && tsc -b --noEmit",
|
||||
"preview": "vite preview",
|
||||
"start": "vite preview --host 0.0.0.0 --port 3000",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"format": "prettier --ignore-unknown --write .",
|
||||
"format:check": "prettier --check ."
|
||||
|
||||
3100
frontend/pnpm-lock.yaml
generated
3100
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
37
frontend/src/components/vendors/per-page-selector.tsx
vendored
Normal file
37
frontend/src/components/vendors/per-page-selector.tsx
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
type Props = {
|
||||
value: number
|
||||
options?: readonly number[]
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS = [12, 24, 48] as const
|
||||
|
||||
export function PerPageSelector({ value, options = DEFAULT_OPTIONS, onChange }: Props) {
|
||||
return (
|
||||
<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">
|
||||
{options.map((n, i) => (
|
||||
<Button
|
||||
key={n}
|
||||
variant={value === n ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
disabled={value === n}
|
||||
onClick={() => onChange(n)}
|
||||
className={cn(
|
||||
'rounded-none',
|
||||
i === 0 && 'rounded-l-md',
|
||||
i === options.length - 1 && 'rounded-r-md',
|
||||
i > 0 && 'border-l',
|
||||
)}
|
||||
>
|
||||
{n}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
frontend/src/components/vendors/types.ts
vendored
Normal file
8
frontend/src/components/vendors/types.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export type VendorLayout = 'GRID' | 'LIST'
|
||||
|
||||
export 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) + ' …'
|
||||
}
|
||||
53
frontend/src/components/vendors/vendor-grid.tsx
vendored
Normal file
53
frontend/src/components/vendors/vendor-grid.tsx
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { Card, CardContent } from '~/components/ui/card'
|
||||
import { Skeleton } from '~/components/ui/skeleton'
|
||||
import { assetUrl } from '~/lib/directus'
|
||||
import type { VendorListItem } from '~/lib/types'
|
||||
|
||||
import { truncateDescription } from './types'
|
||||
|
||||
type Props = {
|
||||
vendors: VendorListItem[]
|
||||
isLoading: boolean
|
||||
perPage: number
|
||||
}
|
||||
|
||||
export function VendorGrid({ vendors, isLoading, perPage }: Props) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{isLoading
|
||||
? 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>
|
||||
)
|
||||
}
|
||||
57
frontend/src/components/vendors/vendor-list.tsx
vendored
Normal file
57
frontend/src/components/vendors/vendor-list.tsx
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { Skeleton } from '~/components/ui/skeleton'
|
||||
import { assetUrl } from '~/lib/directus'
|
||||
import type { VendorListItem } from '~/lib/types'
|
||||
|
||||
import { truncateDescription } from './types'
|
||||
|
||||
type Props = {
|
||||
vendors: VendorListItem[]
|
||||
isLoading: boolean
|
||||
perPage: number
|
||||
}
|
||||
|
||||
export function VendorList({ vendors, isLoading, perPage }: Props) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<tbody>
|
||||
{isLoading
|
||||
? 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>
|
||||
)
|
||||
}
|
||||
113
frontend/src/components/vendors/vendor-toolbar.tsx
vendored
Normal file
113
frontend/src/components/vendors/vendor-toolbar.tsx
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Check, Filter, FilterX, LayoutGrid, List, Loader2, Search, X } from 'lucide-react'
|
||||
|
||||
import { Button } from '~/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '~/components/ui/dropdown-menu'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import type { Category } from '~/lib/types'
|
||||
|
||||
import type { VendorLayout } from './types'
|
||||
|
||||
type Props = {
|
||||
searchInput: string
|
||||
onSearchInput: (value: string) => void
|
||||
onSearchClear: () => void
|
||||
isFetching: boolean
|
||||
categories: Category[]
|
||||
category: string
|
||||
onCategoryChange: (slug: string | undefined) => void
|
||||
layout: VendorLayout
|
||||
onLayoutChange: (layout: VendorLayout) => void
|
||||
}
|
||||
|
||||
export function VendorToolbar({
|
||||
searchInput,
|
||||
onSearchInput,
|
||||
onSearchClear,
|
||||
isFetching,
|
||||
categories,
|
||||
category,
|
||||
onCategoryChange,
|
||||
layout,
|
||||
onLayoutChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<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) => onSearchInput(e.target.value.toLowerCase())}
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
onClick={onSearchClear}
|
||||
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={() => onCategoryChange(cat.slug)}
|
||||
>
|
||||
{category === cat.slug && <Check className="h-4 w-4" />}
|
||||
{cat.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => onCategoryChange(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={() => onLayoutChange('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={() => onLayoutChange('LIST')}
|
||||
aria-label="List layout"
|
||||
disabled={layout === 'LIST'}
|
||||
>
|
||||
<List />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,8 @@ export function useLocalStorage<T>(key: string, initialValue: T): [T, (v: T) =>
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
return raw != null ? (JSON.parse(raw) as T) : initialValue
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.warn(`useLocalStorage: failed to read "${key}"`, err)
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
@@ -14,8 +15,8 @@ export function useLocalStorage<T>(key: string, initialValue: T): [T, (v: T) =>
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
} catch (err) {
|
||||
console.warn(`useLocalStorage: failed to write "${key}"`, err)
|
||||
}
|
||||
}, [key, value])
|
||||
|
||||
|
||||
@@ -20,6 +20,30 @@ export type DirectusQuery = {
|
||||
meta?: string[]
|
||||
}
|
||||
|
||||
export class DirectusError extends Error {
|
||||
readonly status: number
|
||||
readonly statusText: string
|
||||
readonly collection: string
|
||||
readonly url: string
|
||||
readonly body: unknown
|
||||
|
||||
constructor(args: {
|
||||
status: number
|
||||
statusText: string
|
||||
collection: string
|
||||
url: string
|
||||
body: unknown
|
||||
}) {
|
||||
super(`Directus ${args.status} ${args.statusText} on /items/${args.collection}`)
|
||||
this.name = 'DirectusError'
|
||||
this.status = args.status
|
||||
this.statusText = args.statusText
|
||||
this.collection = args.collection
|
||||
this.url = args.url
|
||||
this.body = args.body
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(collection: string, q: DirectusQuery = {}): string {
|
||||
const url = new URL(`${DIRECTUS_URL}/items/${collection}`)
|
||||
for (const field of q.fields ?? []) url.searchParams.append('fields[]', field)
|
||||
@@ -31,13 +55,32 @@ function buildUrl(collection: string, q: DirectusQuery = {}): string {
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function fetchOrThrow(collection: string, url: string): Promise<unknown> {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
let parsed: unknown = body
|
||||
try {
|
||||
parsed = body ? JSON.parse(body) : ''
|
||||
} catch {
|
||||
// body wasn't JSON; keep raw text
|
||||
}
|
||||
throw new DirectusError({
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
collection,
|
||||
url,
|
||||
body: parsed,
|
||||
})
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function directusList<T>(
|
||||
collection: string,
|
||||
q: DirectusQuery = {},
|
||||
): Promise<DirectusListResponse<T>> {
|
||||
const res = await fetch(buildUrl(collection, q))
|
||||
if (!res.ok) throw new Error(`Directus error ${res.status} on /items/${collection}`)
|
||||
return (await res.json()) as DirectusListResponse<T>
|
||||
return (await fetchOrThrow(collection, buildUrl(collection, q))) as DirectusListResponse<T>
|
||||
}
|
||||
|
||||
export async function directusOne<T>(collection: string, q: DirectusQuery = {}): Promise<T | undefined> {
|
||||
@@ -46,11 +89,7 @@ export async function directusOne<T>(collection: string, q: DirectusQuery = {}):
|
||||
}
|
||||
|
||||
export async function directusSingleton<T>(collection: string, q: DirectusQuery = {}): Promise<T> {
|
||||
const url = new URL(`${DIRECTUS_URL}/items/${collection}`)
|
||||
for (const field of q.fields ?? []) url.searchParams.append('fields[]', field)
|
||||
const res = await fetch(url.toString())
|
||||
if (!res.ok) throw new Error(`Directus error ${res.status} on /items/${collection}`)
|
||||
const { data } = (await res.json()) as DirectusItemResponse<T>
|
||||
const { data } = (await fetchOrThrow(collection, buildUrl(collection, q))) as DirectusItemResponse<T>
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
224
frontend/src/routes/vendors/index.tsx
vendored
224
frontend/src/routes/vendors/index.tsx
vendored
@@ -1,28 +1,20 @@
|
||||
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 { createFileRoute } from '@tanstack/react-router'
|
||||
import { SmilePlus } 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 { PerPageSelector } from '~/components/vendors/per-page-selector'
|
||||
import type { VendorLayout } from '~/components/vendors/types'
|
||||
import { VendorGrid } from '~/components/vendors/vendor-grid'
|
||||
import { VendorList } from '~/components/vendors/vendor-list'
|
||||
import { VendorToolbar } from '~/components/vendors/vendor-toolbar'
|
||||
|
||||
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
|
||||
@@ -43,15 +35,6 @@ export const Route = createFileRoute('/vendors/')({
|
||||
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()
|
||||
@@ -59,7 +42,7 @@ function VendorsPage() {
|
||||
const { data: categories } = useSuspenseQuery(categoriesQuery)
|
||||
|
||||
const [perPage, setPerPage] = useLocalStorage<number>('perPage', 12)
|
||||
const [layout, setLayout] = useLocalStorage<Layout>('layout', 'GRID')
|
||||
const [layout, setLayout] = useLocalStorage<VendorLayout>('layout', 'GRID')
|
||||
|
||||
const [searchInput, setSearchInput] = useState(q)
|
||||
useEffect(() => {
|
||||
@@ -86,6 +69,7 @@ function VendorsPage() {
|
||||
}
|
||||
}, [response, page, lastPage, navigate])
|
||||
|
||||
const isLoading = isFetching && !response
|
||||
const isEmpty = !isFetching && filterCount === 0
|
||||
|
||||
return (
|
||||
@@ -94,91 +78,25 @@ function VendorsPage() {
|
||||
<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()
|
||||
<VendorToolbar
|
||||
searchInput={searchInput}
|
||||
onSearchInput={(v) => {
|
||||
setSearchInput(v)
|
||||
pushSearch(v)
|
||||
}}
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
onSearchClear={() => {
|
||||
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 }) })
|
||||
isFetching={isFetching}
|
||||
categories={categories}
|
||||
category={category}
|
||||
onCategoryChange={(slug) =>
|
||||
navigate({ search: (s) => ({ ...s, category: 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>
|
||||
layout={layout}
|
||||
onLayoutChange={setLayout}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="my-3">
|
||||
@@ -187,84 +105,13 @@ function VendorsPage() {
|
||||
)}
|
||||
|
||||
{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>
|
||||
<VendorGrid vendors={vendors} isLoading={isLoading} perPage={perPage} />
|
||||
) : (
|
||||
<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>
|
||||
<VendorList vendors={vendors} isLoading={isLoading} perPage={perPage} />
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<Alert variant="info" className={cn('my-3 rounded-md py-10')}>
|
||||
<Alert variant="info" className="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>
|
||||
@@ -287,28 +134,7 @@ function VendorsPage() {
|
||||
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>
|
||||
<PerPageSelector value={perPage} onChange={setPerPage} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -24,6 +24,10 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
allowedHosts: ['pca-pijac.dev.civokram.com'],
|
||||
},
|
||||
build: {
|
||||
outDir: 'node_modules/.cache/dist',
|
||||
emptyOutDir: true,
|
||||
|
||||
Reference in New Issue
Block a user