Compare commits

...

10 Commits

37 changed files with 1218 additions and 4517 deletions

View File

@@ -1,6 +1,7 @@
.DEFAULT_GOAL := help
.PHONY: help install rebuild init dev dev-backend dev-frontend build start \
db-import db-dump admin clean clean-db docker-up docker-down docker-logs
typecheck format db-import db-dump admin clean clean-db \
docker-up docker-down docker-logs
help:
@echo "PCA Pijac — available targets:"
@@ -10,9 +11,11 @@ help:
@echo " make rebuild Rebuild native modules (argon2, sqlite3, sharp)"
@echo " make dev Run backend + frontend in dev (parallel)"
@echo " make dev-backend Run Directus backend only (nodemon)"
@echo " make dev-frontend Run Next.js frontend only"
@echo " make dev-frontend Run Vite frontend only"
@echo " make build Build frontend for production"
@echo " make start Start backend + frontend in production mode"
@echo " make typecheck Typecheck frontend (tsc -b --noEmit)"
@echo " make format Prettier format frontend"
@echo ""
@echo " make db-import Reset data/data.db from schema/dump.sql"
@echo " make db-dump Dump current DB to schema/dump.sql"
@@ -28,12 +31,9 @@ help:
install:
pnpm install
cd backend && pnpm install
cd frontend && pnpm install
rebuild:
cd backend && pnpm install --force
cd frontend && pnpm install --force
pnpm install --force
init: install rebuild db-import
@echo "Init complete. Run 'make admin EMAIL=... PASSWORD=...' to create an admin user."
@@ -50,9 +50,15 @@ dev-frontend:
build:
cd frontend && pnpm run build
typecheck:
cd frontend && pnpm run typecheck
format:
cd frontend && pnpm run format
start:
cd backend && pnpm run start & \
cd frontend && pnpm run start; \
cd frontend && pnpm run preview; \
wait
db-import:
@@ -81,7 +87,7 @@ docker-logs:
docker compose logs -f
clean:
rm -rf node_modules backend/node_modules frontend/node_modules frontend/.next
rm -rf node_modules backend/node_modules frontend/node_modules
clean-db:
rm -f data/data.db

View File

@@ -17,6 +17,7 @@
"format:check": "prettier --check ."
},
"dependencies": {
"@icons-pack/react-simple-icons": "13.13.0",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-separator": "1.1.8",
@@ -26,26 +27,24 @@
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"iso-3166": "4.4.0",
"lucide-react": "0.468.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-helmet-async": "2.0.5",
"tailwind-merge": "2.6.1",
"tw-animate-css": "1.3.0"
"lucide-react": "1.9.0",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-helmet-async": "3.0.0",
"tailwind-merge": "3.5.0",
"tw-animate-css": "1.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.1.16",
"@tanstack/react-query-devtools": "5.100.0",
"@tanstack/react-router-devtools": "1.166.13",
"@tailwindcss/vite": "4.2.4",
"@tanstack/router-plugin": "1.167.22",
"@types/node": "22.19.17",
"@types/react": "18.3.28",
"@types/react-dom": "18.3.7",
"@vitejs/plugin-react": "4.7.0",
"@types/node": "25.6.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.1",
"prettier": "3.8.3",
"prettier-plugin-organize-imports": "4.3.0",
"tailwindcss": "4.1.16",
"typescript": "5.9.3",
"vite": "6.4.2"
"tailwindcss": "4.2.4",
"typescript": "6.0.3",
"vite": "8.0.10"
}
}

View File

@@ -0,0 +1,25 @@
import { AlertTriangle } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
import { Button } from '~/components/ui/button'
type Props = {
error: Error
reset?: () => void
}
export function ErrorState({ error, reset }: Props) {
return (
<Alert variant="destructive" className="my-3 rounded-md py-10">
<div className="flex flex-col items-center gap-2 text-center">
<AlertTriangle className="h-8 w-8" />
<AlertTitle className="mt-4 text-lg">Something went wrong</AlertTitle>
<AlertDescription className="max-w-sm">{error.message}</AlertDescription>
{reset && (
<Button variant="outline" size="sm" onClick={reset} className="mt-3">
Try again
</Button>
)}
</div>
</Alert>
)
}

View File

@@ -0,0 +1,14 @@
import { Frown } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
export function NotFound({ title = '404: Page Not Found', message = 'Try going back or something …' }) {
return (
<Alert variant="info" className="my-3 rounded-md py-10">
<div className="flex flex-col items-center gap-2 text-center">
<Frown className="h-8 w-8" />
<AlertTitle className="mt-4 text-lg">{title}</AlertTitle>
<AlertDescription className="max-w-sm">{message}</AlertDescription>
</div>
</Alert>
)
}

View 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>
)
}

View 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])
}

View 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]
}

View File

@@ -45,6 +45,15 @@ export async function directusOne<T>(collection: string, q: DirectusQuery = {}):
return data[0]
}
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>
return data
}
export function assetUrl(fileId: string | null | undefined, key?: string): string {
if (!fileId) return ''
const url = new URL(`${DIRECTUS_URL}/assets/${fileId}`)

View File

@@ -1,21 +1,13 @@
import { queryOptions } from '@tanstack/react-query'
import { directusList, directusOne } from './directus'
import { GLOBALS_ID } from './env'
import { directusList, directusOne, directusSingleton } from './directus'
import type { Category, Globals, Menu, MenuRaw, PageEntity, Vendor, VendorListItem } from './types'
export const globalsQuery = queryOptions({
queryKey: ['globals'],
queryFn: async (): Promise<Globals> => {
const item = await directusOne<Globals>('globals', { fields: ['*'] })
if (!item) throw new Error('globals not found')
return item
},
queryFn: () => directusSingleton<Globals>('globals', { fields: ['*'] }),
staleTime: 5 * 60_000,
})
// globals is a singleton; if the ID ever changes, we could filter explicitly.
export { GLOBALS_ID }
export const menusQuery = queryOptions({
queryKey: ['menus'],
queryFn: async (): Promise<Menu[]> => {

View File

@@ -1,6 +1,7 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute, notFound } from '@tanstack/react-router'
import { Helmet } from 'react-helmet-async'
import { NotFound } from '~/components/not-found'
import { PageView } from '~/components/page-view'
import { pageBySlugQuery } from '~/lib/queries'
@@ -10,6 +11,7 @@ export const Route = createFileRoute('/$slug')({
if (!page) throw notFound()
},
component: CmsPage,
notFoundComponent: () => <NotFound />,
})
function CmsPage() {

View File

@@ -1,24 +1,27 @@
import type { QueryClient } from '@tanstack/react-query'
import { useSuspenseQuery } from '@tanstack/react-query'
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import { lazy, Suspense } from 'react'
import { Helmet } from 'react-helmet-async'
import { ErrorState } from '~/components/error-state'
import { Shell } from '~/components/layout/shell'
import { NotFound } from '~/components/not-found'
import { globalsQuery, menusQuery } from '~/lib/queries'
const TanStackRouterDevtools = import.meta.env.PROD
? () => null
: lazy(() => import('@tanstack/react-router-devtools').then((m) => ({ default: m.TanStackRouterDevtools })))
const ReactQueryDevtools = import.meta.env.PROD
? () => null
: lazy(() => import('@tanstack/react-query-devtools').then((m) => ({ default: m.ReactQueryDevtools })))
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
loader: async ({ context: { queryClient } }) => {
await Promise.all([queryClient.ensureQueryData(globalsQuery), queryClient.ensureQueryData(menusQuery)])
},
component: RootComponent,
notFoundComponent: () => (
<Shell>
<NotFound />
</Shell>
),
errorComponent: ({ error, reset }) => (
<Shell>
<ErrorState error={error} reset={reset} />
</Shell>
),
})
function RootComponent() {
@@ -31,10 +34,6 @@ function RootComponent() {
<Shell>
<Outlet />
</Shell>
<Suspense>
<TanStackRouterDevtools />
<ReactQueryDevtools buttonPosition="bottom-left" />
</Suspense>
</>
)
}

150
frontend/src/routes/vendors/$slug.tsx vendored Normal file
View File

@@ -0,0 +1,150 @@
import { SiFacebook, SiX } from '@icons-pack/react-simple-icons'
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute, Link, notFound } from '@tanstack/react-router'
import { iso31661 } from 'iso-3166'
import { ExternalLink, Globe } from 'lucide-react'
import { Helmet } from 'react-helmet-async'
import { Button } from '~/components/ui/button'
import { assetUrl } from '~/lib/directus'
import { vendorBySlugQuery } from '~/lib/queries'
export const Route = createFileRoute('/vendors/$slug')({
loader: async ({ context: { queryClient }, params: { slug } }) => {
const vendor = await queryClient.ensureQueryData(vendorBySlugQuery(slug))
if (!vendor) throw notFound()
},
component: VendorDetailPage,
})
function countryName(alpha3: string | null | undefined): string {
if (!alpha3) return ''
return iso31661.find((iso) => iso.alpha3 === alpha3)?.name ?? ''
}
function VendorDetailPage() {
const { slug } = Route.useParams()
const { data: vendor } = useSuspenseQuery(vendorBySlugQuery(slug))
if (!vendor) return null
const country = countryName(vendor.country)
const hasSocial = vendor.website || vendor.facebook || vendor.linkedin || vendor.twitter
const hasAddress = vendor.address_line_1 || vendor.address_line_2 || vendor.city || vendor.state || country
return (
<article className="vendor">
<Helmet>
<title>{vendor.name}</title>
</Helmet>
<div className="flex flex-col items-start justify-between gap-6 md:flex-row md:items-center">
<h1 className="text-3xl font-bold">{vendor.name}</h1>
{vendor.logo && (
<img
src={assetUrl(vendor.logo, 'logo-page')}
alt={vendor.name}
width={350}
height={150}
className="h-[150px] w-[350px] rounded-md bg-white object-contain"
/>
)}
</div>
{vendor.long_description && (
<div className="prose-cms mt-6" dangerouslySetInnerHTML={{ __html: vendor.long_description }} />
)}
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-2 lg:grid-cols-3">
{hasAddress && (
<section>
<h2 className="mt-6 mb-2 text-lg font-semibold">Address</h2>
<p className="text-sm">
{vendor.address_line_1 && (
<>
{vendor.address_line_1}
<br />
</>
)}
{vendor.address_line_2 && (
<>
{vendor.address_line_2}
<br />
</>
)}
{vendor.city && (
<>
{vendor.city}
<br />
</>
)}
{vendor.state && (
<>
{vendor.state}
<br />
</>
)}
{country && <>{country}</>}
</p>
</section>
)}
{hasSocial && (
<section>
<h2 className="mt-6 mb-2 text-lg font-semibold">Social</h2>
<ul className="space-y-2 text-sm">
{vendor.website && (
<li className="flex items-center gap-2">
<Globe className="h-4 w-4" />
<a href={vendor.website} target="_blank" rel="noreferrer" className="hover:underline">
{vendor.website}
</a>
</li>
)}
{vendor.linkedin && (
<li className="flex items-center gap-2">
<ExternalLink className="h-4 w-4" />
<a href={vendor.linkedin} target="_blank" rel="noreferrer" className="hover:underline">
{vendor.linkedin}
</a>
</li>
)}
{vendor.twitter && (
<li className="flex items-center gap-2">
<SiX className="h-4 w-4" />
<a href={vendor.twitter} target="_blank" rel="noreferrer" className="hover:underline">
{vendor.twitter}
</a>
</li>
)}
{vendor.facebook && (
<li className="flex items-center gap-2">
<SiFacebook className="h-4 w-4" />
<a href={vendor.facebook} target="_blank" rel="noreferrer" className="hover:underline">
{vendor.facebook}
</a>
</li>
)}
</ul>
</section>
)}
{vendor.categories.length > 0 && (
<section>
<h2 className="mt-6 mb-2 text-lg font-semibold">Categories</h2>
<ul className="flex flex-col gap-2">
{vendor.categories.map((cat) => (
<li key={cat.categories_id.slug}>
<Button asChild variant="secondary" size="sm" className="h-6 px-2 text-[11px]">
<Link to="/vendors" search={{ category: cat.categories_id.slug }}>
{cat.categories_id.name}
</Link>
</Button>
</li>
))}
</ul>
</section>
)}
</div>
</article>
)
}

316
frontend/src/routes/vendors/index.tsx vendored Normal file
View 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>
)
}

View File

@@ -17,10 +17,9 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"~/*": ["src/*"],
"~generated/*": ["node_modules/.cache/*"]
"~/*": ["./src/*"],
"~generated/*": ["./node_modules/.cache/*"]
}
},
"include": ["src", "node_modules/.cache/tanstack-router/routeTree.gen.ts"]

View File

@@ -1,4 +0,0 @@
# The URL where your API can be reached on the web.
NEXT_PUBLIC_DIRECTUS_API_URL="http://localhost:8055"
NEXT_PUBLIC_GLOBALS_ID="4f8d9e66-ec95-4bdd-a5e7-34df8ea68a45"

View File

@@ -1,4 +0,0 @@
# The URL where your API can be reached on the web.
NEXT_PUBLIC_DIRECTUS_API_URL=https://admin.pca-pijac.dev.civokram.com
NEXT_PUBLIC_GLOBALS_ID=4f8d9e66-ec95-4bdd-a5e7-34df8ea68a45

View File

@@ -1,35 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,40 +0,0 @@
import { Link } from '@chakra-ui/next-js'
import { Box, Flex, Stack, Text, useColorModeValue } from '@chakra-ui/react'
export default function Footer({ menu, globals }) {
return (
<Box bg={useColorModeValue('gray.100', 'gray.900')} px={4} mt="8">
<Flex h={16} alignItems={'center'} justifyContent="space-between">
<FooterMenu items={menu.items} />
<Text>{globals.copyright}</Text>
</Flex>
</Box>
)
}
const FooterMenu = ({ items }) => {
const linkColor = useColorModeValue('gray.600', 'gray.200')
const linkHoverColor = useColorModeValue('gray.800', 'white')
return (
<Stack direction={'row'} spacing={4} pl="4">
{items.map((navItem) => (
<Box key={navItem.label}>
<Link
p={2}
href={navItem.url ?? '#'}
fontSize={'sm'}
fontWeight={500}
color={linkColor}
_hover={{
textDecoration: 'none',
color: linkHoverColor,
}}
>
{navItem.label}
</Link>
</Box>
))}
</Stack>
)
}

View File

@@ -1,53 +0,0 @@
import { MoonIcon, SunIcon } from '@chakra-ui/icons'
import { Box, Button, Flex, Heading, Spacer, Stack, useColorMode, useColorModeValue } from '@chakra-ui/react'
import { Link } from '@chakra-ui/next-js'
export default function Header({ menu, globals }) {
const { colorMode, toggleColorMode } = useColorMode()
return (
<>
<Box bg={useColorModeValue('gray.100', 'gray.900')} px={4}>
<Flex h={16} alignItems={'center'}>
<Heading size="md">{globals.site_name}</Heading>
<MainMenu items={menu.items} />
<Spacer />
<Flex alignItems={'center'}>
<Stack direction={'row'} spacing={7}>
<Button onClick={toggleColorMode}>{colorMode === 'light' ? <MoonIcon /> : <SunIcon />}</Button>
</Stack>
</Flex>
</Flex>
</Box>
</>
)
}
const MainMenu = ({ items }) => {
const linkColor = useColorModeValue('gray.600', 'gray.200')
const linkHoverColor = useColorModeValue('gray.800', 'white')
return (
<Stack direction={'row'} spacing={4} pl="4">
{items.map((navItem) => (
<Box key={navItem.label}>
<Link
p={2}
href={navItem.url ?? '#'}
fontSize={'sm'}
fontWeight={500}
color={linkColor}
_hover={{
textDecoration: 'none',
color: linkHoverColor,
}}
>
{navItem.label}
</Link>
</Box>
))}
</Stack>
)
}

View File

@@ -1,13 +0,0 @@
import { Container, Box } from '@chakra-ui/react'
import Footer from '~/components/footer'
import Header from '~/components/header'
export default function Layout({ globals, menus, children }) {
return (
<Container maxW={'8xl'}>
<Header menu={menus.find((m) => m.id === 'MAIN_MENU')} globals={globals} />
<Box marginY="6">{children}</Box>
<Footer menu={menus.find((m) => m.id === 'FOOTER_MENU')} globals={globals} />
</Container>
)
}

View File

@@ -1,78 +0,0 @@
import { Flex, Link, Select, Text } from '@chakra-ui/react'
import clsx from 'clsx'
import NextLink from 'next/link'
import { useRouter } from 'next/router'
import { Fragment } from 'react'
export default function Pagination({ page = 1, itemsPerPage = 12, totalItems = 0, limit = 3 }) {
const router = useRouter()
const totalPages = Math.ceil(totalItems / itemsPerPage)
if (totalPages === 1) return <></>
const links = new Array(totalPages)
.fill(true)
.map((_v, i) => (i < limit || i >= totalPages - limit || (i >= page - limit && i < page + limit - 1)) && i + 1)
.filter((i) => i !== false)
return (
<>
<Flex justifyContent="space-between" direction={['column', 'row']} mt={6} mb={6} gap={3}>
<Link
//
as={NextLink}
href={{ query: { ...router.query, page: Math.max(1, page - 1) } }}
variant="pagination"
>
&larr; Previous
</Link>
<Flex gap={1} display={['none', 'none', 'flex']}>
{links.map((v, i) => (
<Fragment key={i}>
{v > 1 && v - 1 !== links[i - 1] && (
<Text marginX={3} lineHeight={10}>
&hellip;
</Text>
)}
<Link
//
key={v}
as={NextLink}
prefetch={false}
href={{ query: { ...router.query, page: v } }}
className={clsx({
current: v === page,
})}
variant="pagination"
>
{v}
</Link>
</Fragment>
))}
</Flex>
<Select
value={page}
onChange={(v) => router.push({ query: { ...router.query, page: v.target.value } })}
display={['flex', 'flex', 'none']}
textAlign="center"
>
{new Array(totalPages).fill(true).map((v, i) => (
<option value={i + 1} key={i}>
{i + 1}
</option>
))}
</Select>
<Link
//
as={NextLink}
href={{ query: { ...router.query, page: Math.min(totalPages, page + 1) } }}
variant="pagination"
textAlign="right"
>
Next &rarr;
</Link>
</Flex>
</>
)
}

View File

@@ -1,7 +0,0 @@
{
"compilerOptions": {
"paths": {
"~/*": ["./*"]
}
}
}

View File

@@ -1,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

View File

@@ -1,38 +0,0 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22",
"pnpm": ">=10"
},
"packageManager": "pnpm@10.33.2",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@chakra-ui/icons": "2.0.19",
"@chakra-ui/next-js": "2.1.4",
"@chakra-ui/react": "2.7.1",
"@emotion/react": "11.11.1",
"@emotion/styled": "11.11.0",
"@react-hookz/web": "23.1.0",
"clsx": "1.2.1",
"iso-3166": "4.2.0",
"next": "13.4.9",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "4.10.1",
"sass": "1.63.6",
"swr": "2.2.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp",
"esbuild"
]
}
}

View File

@@ -1,14 +0,0 @@
import { Alert, AlertDescription, AlertTitle } from '@chakra-ui/react'
import { TbMoodSad } from 'react-icons/tb'
export default function Custom404() {
return (
<Alert status="info" flexDirection="column" justifyContent="center" rounded="md" paddingY="10">
<TbMoodSad style={{ width: '2rem', height: '2rem' }} />
<AlertTitle mt={4} mb={1} fontSize="lg">
404: Page Not Found
</AlertTitle>
<AlertDescription maxWidth="sm">Try going back or something &hellip;</AlertDescription>
</Alert>
)
}

View File

@@ -1,14 +0,0 @@
import { Alert, AlertDescription, AlertTitle } from '@chakra-ui/react'
import { TbMoodSad } from 'react-icons/tb'
export default function Custom500() {
return (
<Alert status="info" flexDirection="column" justifyContent="center" rounded="md" paddingY="10">
<TbMoodSad style={{ width: '2rem', height: '2rem' }} />
<AlertTitle mt={4} mb={1} fontSize="lg">
500: Server-side error occurred
</AlertTitle>
<AlertDescription maxWidth="sm">Something happened that was totally unexpected &hellip;</AlertDescription>
</Alert>
)
}

View File

@@ -1,41 +0,0 @@
import { Box, Heading } from '@chakra-ui/react'
import Head from 'next/head'
export const getStaticPaths = async () => {
const url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/pages`)
url.searchParams.append('fields[]', 'slug')
url.searchParams.append('limit', -1)
const res = await fetch(url.toString())
const { data: pages } = await res.json()
return {
paths: pages.map((p) => ({ params: { slug: p.slug } })),
fallback: false, // false or "blocking"
}
}
export const getStaticProps = async ({ params: { slug } }) => {
const url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/pages`)
url.searchParams.append('fields[]', 'title')
url.searchParams.append('fields[]', 'content')
url.searchParams.append('limit', 1)
url.searchParams.append('filter', JSON.stringify({ slug: { _eq: slug } }))
const res = await fetch(url.toString())
const {
data: [page],
} = await res.json()
return { props: { page } }
}
export default function Page({ globals, page }) {
return (
<Box className="page">
<Head>{page.title && <title>{[page.title, globals.meta_title].join(' — ')}</title>}</Head>
<Heading>{page.title}</Heading>
<div dangerouslySetInnerHTML={{ __html: page.content }}></div>
</Box>
)
}

View File

@@ -1,49 +0,0 @@
import { ChakraProvider } from '@chakra-ui/react'
import App from 'next/app'
import Head from 'next/head'
import Layout from '~/components/layout'
import '~/src/style.scss'
import theme from '~/src/theme'
export default function MyApp({ Component, pageProps, globals, menus }) {
return (
<ChakraProvider theme={theme}>
<Head>
<title>{globals.meta_title}</title>
<meta name="description" content={globals.meta_description} />
</Head>
<Layout globals={globals} menus={menus}>
<Component {...pageProps} globals={globals} />
</Layout>
</ChakraProvider>
)
}
MyApp.getInitialProps = async (context) => {
const pageProps = await App.getInitialProps(context)
let url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/globals`)
url.searchParams.append('fields[]', '*')
url.searchParams.append('limit', 1)
const resG = await fetch(url.toString())
let { data: globals } = await resG.json()
url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/menus`)
url.searchParams.append('fields[]', '*')
url.searchParams.append('fields[]', 'menus_menu_items.sort')
url.searchParams.append('fields[]', 'menus_menu_items.menu_items_id.label')
url.searchParams.append('fields[]', 'menus_menu_items.menu_items_id.url')
url.searchParams.append('fields[]', 'menus_menu_items.menu_items_id.sort')
url.searchParams.append('limit', -1)
const resM = await fetch(url.toString())
const { data: menus } = await resM.json()
return {
...pageProps,
globals,
menus: menus.map((m) => ({
id: m.id,
items: m.menus_menu_items.sort((a, b) => a.sort - b.sort).map((mm) => mm.menu_items_id),
})),
}
}

View File

@@ -1,8 +0,0 @@
export default function Error(props) {
return (
<>
<h2>Oops, there is an error!</h2>
<pre>{JSON.stringify(props, null, 2)}</pre>
</>
)
}

View File

@@ -1,9 +0,0 @@
import Page, { getStaticProps as gsp } from '~/pages/[slug]'
export const getStaticProps = async () => {
return gsp({ params: { slug: 'home' } })
}
export default function Home(props) {
return <Page {...props} />
}

View File

@@ -1,164 +0,0 @@
import { Link } from '@chakra-ui/next-js'
import { Box, Button, Flex, Heading, Image, List, ListIcon, ListItem, SimpleGrid, Text } from '@chakra-ui/react'
import { iso31661 } from 'iso-3166'
import Head from 'next/head'
import { FaFacebookSquare, FaLinkedin, FaTwitterSquare } from 'react-icons/fa'
import { TbWorldWww } from 'react-icons/tb'
export const getStaticPaths = async () => {
const url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/vendors`)
url.searchParams.append('fields[]', 'slug')
url.searchParams.append('limit', -1)
const res = await fetch(url.toString())
const { data: vendors } = await res.json()
return {
paths: vendors.map((v) => ({ params: { slug: v.slug } })),
fallback: false, // false or "blocking"
}
}
export const getStaticProps = async ({ params: { slug } }) => {
const url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/vendors`)
url.searchParams.append('fields[]', '*')
url.searchParams.append('fields[]', 'categories.categories_id.slug')
url.searchParams.append('fields[]', 'categories.categories_id.name')
url.searchParams.append('fields[]', 'categories.categories_id.parent_id')
url.searchParams.append('fields[]', 'categories.categories_id.subcategories.slug')
url.searchParams.append('fields[]', 'categories.categories_id.subcategories.name')
url.searchParams.append('limit', 1)
url.searchParams.append('filter', JSON.stringify({ slug: { _eq: slug } }))
const res = await fetch(url.toString())
const {
data: [vendor],
} = await res.json()
return {
props: { vendor: { ...vendor, country: iso31661.find((iso) => iso.alpha3 === vendor.country)?.name || '' } },
}
}
export default function VendorPage({ globals, vendor }) {
return (
<Box className="vendor">
<Head>
<title>{[vendor.name, globals.meta_title].join(' — ')}</title>
</Head>
<Flex justifyContent="space-between" direction={['column', 'column', 'row']}>
<Heading marginY="6">{vendor.name}</Heading>
<Image
rounded={4}
objectFit="contain"
src={`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/assets/${vendor.logo}?key=logo-page`}
alt={vendor.name}
width="350px"
height="150px"
bg="#fff"
/>
</Flex>
<Box dangerouslySetInnerHTML={{ __html: vendor.long_description }}></Box>
<SimpleGrid columns={[1, 2, 3]} spacing={10}>
<Box>
<Heading size="md" mt="6" mb="1">
Address
</Heading>
<Text>
{vendor.address_line_1 && (
<>
{vendor.address_line_1}
<br />
</>
)}
{vendor.address_line_2 && (
<>
{vendor.address_line_2}
<br />
</>
)}
{vendor.city && (
<>
{vendor.city}
<br />
</>
)}
{vendor.state && (
<>
{vendor.state}
<br />
</>
)}
{vendor.country && <>{vendor.country}</>}
</Text>
</Box>
<Box>
{(vendor.website || vendor.facebook || vendor.linkedin || vendor.twitter) && (
<>
<Heading size="md" mt="6" mb="1">
Social
</Heading>
<List>
{vendor.website && (
<ListItem>
<ListIcon as={TbWorldWww} />
{vendor.website}
</ListItem>
)}
{vendor.linkedin && (
<ListItem>
<ListIcon as={FaLinkedin} />
{vendor.linkedin}
</ListItem>
)}
{vendor.twitter && (
<ListItem>
<ListIcon as={FaTwitterSquare} />
{vendor.twitter}
</ListItem>
)}
{vendor.facebook && (
<ListItem>
<ListIcon as={FaFacebookSquare} />
{vendor.facebook}
</ListItem>
)}
</List>
</>
)}
</Box>
<Box>
{vendor.categories.length > 0 && (
<>
<Heading size="md" mt="6" mb="1">
Categories
</Heading>
<List spacing="3">
{vendor.categories.map((cat) => (
<ListItem key={cat.categories_id.slug}>
<Button
as={Link}
fontSize="11"
href={{
pathname: '/vendors',
query: {
category: cat.categories_id.slug,
},
}}
paddingX="0.5rem"
height="1.5rem"
>
{cat.categories_id.name}
</Button>
</ListItem>
))}
</List>
</>
)}
</Box>
</SimpleGrid>
</Box>
)
}

View File

@@ -1,310 +0,0 @@
import { Link } from '@chakra-ui/next-js'
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Box,
Button,
ButtonGroup,
Card,
CardBody,
Flex,
HStack,
Heading,
IconButton,
Image,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
LinkBox,
LinkOverlay,
Menu,
MenuButton,
MenuDivider,
MenuItem,
MenuList,
SimpleGrid,
Skeleton,
Spinner,
Table,
Tbody,
Td,
Text,
Tr,
} from '@chakra-ui/react'
import { useDebouncedCallback, useLocalStorageValue } from '@react-hookz/web'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { TbCheck, TbFilter, TbFilterEdit, TbLayoutGrid, TbLayoutList, TbMoodSad, TbSearch, TbX } from 'react-icons/tb'
import useSWR from 'swr'
import Pagination from '~/components/pagination'
const PER_PAGE = 12
export const getStaticProps = async () => {
const url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/categories`)
url.searchParams.append('fields[]', 'slug')
url.searchParams.append('fields[]', 'name')
url.searchParams.append('fields[]', 'subcategories.slug')
url.searchParams.append('fields[]', 'subcategories.name')
url.searchParams.append('sort', 'name')
url.searchParams.append('limit', -1)
url.searchParams.append(
'filter',
JSON.stringify({
parent_id: {
_null: true,
},
})
)
const res = await fetch(url.toString())
const { data: categories } = await res.json()
return { props: { categories } }
}
export default function VendorsPage({ globals, categories }) {
const router = useRouter()
const {
query: { page = 1, category = '', q: search = '' },
} = router
const { value: perPage, set: setPerPage } = useLocalStorageValue('perPage', {
defaultValue: PER_PAGE,
initializeWithValue: false,
})
const { value: layout, set: setLayout } = useLocalStorageValue('layout', {
defaultValue: 'GRID',
initializeWithValue: false,
})
const setSearch = useDebouncedCallback(
(q) => {
router.replace({ query: { ...router.query, q } })
},
[router],
500
)
const { data, error, isLoading, isValidating } = useSWR(['vendors', page, perPage, search, category], async () => {
if (!perPage) return
const url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/vendors`)
url.searchParams.append('fields[]', '*')
url.searchParams.append('limit', perPage)
url.searchParams.append('page', page)
url.searchParams.append('sort', 'name')
url.searchParams.append('meta[]', 'filter_count')
if (search !== '' || category !== '') {
url.searchParams.append(
'filter',
JSON.stringify({
_and: [
category !== '' ? { categories: { categories_id: { slug: { _eq: category } } } } : {},
search !== ''
? {
_or: ['name', 'description', 'long_description', 'city'].map((s) => ({
[s]: { _contains: search },
})),
}
: {},
],
})
)
}
const res = await fetch(url.toString())
if (!res.ok) {
throw new Error('Oops')
}
return res.json()
})
const { meta, data: vendors } = data || { meta: {}, data: [] }
const lastPage = Math.max(1, Math.ceil(meta.filter_count / perPage))
if (!isNaN(lastPage) && page > lastPage) {
router.replace({ query: { ...router.query, page: lastPage } })
}
return (
<Box className="vendors">
<Head>
<title>{['Vendors', globals.meta_title].join(' — ')}</title>
</Head>
<Flex alignItems="center" justifyContent="space-between" direction={['column', 'row']} mb="5" gap="5">
<Heading size="lg">Vendors</Heading>
<Box>{isValidating && <Spinner />}</Box>
<HStack gap="5">
<InputGroup>
<InputLeftElement>
<TbSearch />
</InputLeftElement>
<Input onChange={(ev) => setSearch(ev.target.value.toLowerCase())} />
<InputRightElement
onClick={(ev) => {
ev.currentTarget.parentNode.childNodes[1].value = ''
setSearch('')
}}
>
{search !== '' && <TbX />}
</InputRightElement>
</InputGroup>
<Menu>
<MenuButton as={IconButton} icon={category === '' ? <TbFilter /> : <TbFilterEdit />} />
<MenuList>
{categories.map((cat) => (
<Link key={cat.slug} prefetch={false} href={{ query: { ...router.query, category: cat.slug } }}>
<MenuItem icon={category === cat.slug && <TbCheck />} isDisabled={category === cat.slug}>
{cat.name}
</MenuItem>
</Link>
))}
<MenuDivider />
<Link href={{ query: { ...router.query, category: '' } }}>
<MenuItem>Clear</MenuItem>
</Link>
</MenuList>
</Menu>
<ButtonGroup isAttached>
<IconButton icon={<TbLayoutGrid />} onClick={() => setLayout('GRID')} isDisabled={layout === 'GRID'} />
<IconButton icon={<TbLayoutList />} onClick={() => setLayout('LIST')} isDisabled={layout === 'LIST'} />
</ButtonGroup>
</HStack>
</Flex>
{error && (
<Alert status="error" marginY={3}>
<AlertIcon />
There was an error processing your request
</Alert>
)}
{layout === 'GRID' && (
<SimpleGrid columns={[1, 2, 3, 4]} spacing={3}>
{isLoading &&
new Array(perPage).fill(true).map((v, k) => (
<Card key={k}>
<Skeleton h="40" />
<CardBody>
<Skeleton h="6" mb="2" />
<Skeleton h="4" mb="1" />
<Skeleton h="4" />
</CardBody>
</Card>
))}
{!isLoading &&
vendors.map((v) => (
<LinkBox as={Card} rounded={4} key={v.id}>
<Image
roundedTop={4}
objectFit="contain"
src={`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/assets/${v.logo}?key=logo-card`}
alt={v.name}
width="250"
height="150"
style={{ objectFit: 'contain', background: '#fff' }}
/>
<CardBody>
<LinkOverlay as={Link} href={`/vendors/${v.slug}`} prefetch={false}>
<Text mb="2" fontWeight="900">
{v.name}
</Text>
<Text fontSize="sm">
{v?.description?.substring(0, v?.description?.substring(0, 80).lastIndexOf(' '))} &hellip;
</Text>
</LinkOverlay>
</CardBody>
</LinkBox>
))}
</SimpleGrid>
)}
{layout === 'LIST' && (
<>
{isLoading && (
<Table>
<Tbody>
{new Array(perPage).fill(true).map((v, k) => (
<Tr key={k}>
<Td w="10%">
<Skeleton h="12" w="12" />
</Td>
<Td w="30%">
<Skeleton h="6" />
</Td>
<Td w="60%">
<Skeleton h="6" />
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
{!isLoading && (
<Table>
<Tbody>
{vendors.map((v) => (
<Link key={v.id} href={`/vendors/${v.slug}`} prefetch={false} display="contents">
<Tr verticalAlign="middle">
<Td>
<Image
rounded="md"
src={`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/assets/${v.logo}?key=logo-card`}
alt={v.name}
width="12"
height="12"
style={{ objectFit: 'contain', background: '#fff' }}
/>
</Td>
<Td>{v.name}</Td>
<Td>
<Text fontSize="sm">
{v?.description?.substring(0, v?.description?.substring(0, 80).lastIndexOf(' '))} &hellip;
</Text>
</Td>
</Tr>
</Link>
))}
</Tbody>
</Table>
)}
</>
)}
{meta.filter_count === 0 && (
<Box>
<Alert status="info" flexDirection="column" justifyContent="center" rounded="md" paddingY="10">
<TbMoodSad style={{ width: '2rem', height: '2rem' }} />
<AlertTitle mt={4} mb={1} fontSize="lg">
No results found.
</AlertTitle>
<AlertDescription maxWidth="sm">Try refining your search term and filters &hellip;</AlertDescription>
</Alert>
</Box>
)}
{meta.filter_count > perPage && (
<>
<Pagination page={Number(page)} itemsPerPage={perPage} totalItems={meta.filter_count} />
<HStack mt="5" justifyContent="center">
<Text>Results per page:</Text>
<ButtonGroup isAttached>
{[12, 24, 48].map((n) => (
<Button key={n} onClick={() => setPerPage(n)} isDisabled={perPage === n}>
{n}
</Button>
))}
</ButtonGroup>
</HStack>
</>
)}
</Box>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +0,0 @@
.page {
h1,
h2,
h3,
h4,
h5 {
font-family: var(--chakra-fonts-heading);
font-weight: var(--chakra-fontWeights-bold);
margin-top: var(--chakra-space-6);
margin-bottom: var(--chakra-space-1);
}
ul {
margin-left: 2rem;
}
}
.vendor {
h5 {
font-size: var(--chakra-fontSizes-xl);
font-family: var(--chakra-fonts-heading);
font-weight: var(--chakra-fontWeights-bold);
margin-top: var(--chakra-space-6);
margin-bottom: var(--chakra-space-1);
}
}

View File

@@ -1,40 +0,0 @@
import { extendTheme } from '@chakra-ui/react'
import { defineStyle, defineStyleConfig } from '@chakra-ui/react'
const pagination = defineStyle({
whiteSpace: 'nowrap',
bg: 'transparent',
gap: 3,
paddingInlineStart: 3,
paddingInlineEnd: 3,
lineHeight: 10,
borderRadius: 'md',
border: '1px solid',
borderColor: 'inherit',
width: '100%',
_hover: {
textDecor: 'none',
},
'&.current': {
bg: 'var(--chakra-colors-gray-300)',
border: '1px solid transparent',
},
_dark: {
bg: 'var(--chakra-colors-gray-900)',
'&.current': {
bg: 'var(--chakra-colors-gray-700)',
},
},
})
const linkTheme = defineStyleConfig({
variants: { pagination },
})
const theme = extendTheme({
components: {
Link: linkTheme,
},
})
export default theme

View File

@@ -22,6 +22,12 @@
"keyv",
"isolated-vm",
"protobufjs"
]
],
"peerDependencyRules": {
"allowedVersions": {
"@vitejs/plugin-vue>vite": "8",
"vite>esbuild": "0.26"
}
}
}
}

1554
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff