diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
deleted file mode 100644
index 826fb95..0000000
--- a/frontend/src/App.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Button } from '@/components/ui/button'
-
-export default function App() {
- return (
-
-
PCA Pijac
-
- Vite + React + TypeScript + TanStack Router + TanStack Query + Tailwind + shadcn/ui
-
-
-
- )
-}
diff --git a/frontend/src/lib/directus.ts b/frontend/src/lib/directus.ts
new file mode 100644
index 0000000..e24c692
--- /dev/null
+++ b/frontend/src/lib/directus.ts
@@ -0,0 +1,50 @@
+import { DIRECTUS_URL } from './env'
+
+export type DirectusListResponse = {
+ data: T[]
+ meta?: { filter_count?: number; total_count?: number }
+}
+
+export type DirectusItemResponse = {
+ data: T
+}
+
+export type DirectusFilter = Record
+
+export type DirectusQuery = {
+ fields?: string[]
+ filter?: DirectusFilter
+ sort?: string
+ limit?: number
+ page?: number
+ meta?: string[]
+}
+
+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)
+ for (const m of q.meta ?? []) url.searchParams.append('meta[]', m)
+ if (q.filter) url.searchParams.append('filter', JSON.stringify(q.filter))
+ if (q.sort) url.searchParams.append('sort', q.sort)
+ if (q.limit !== undefined) url.searchParams.append('limit', String(q.limit))
+ if (q.page !== undefined) url.searchParams.append('page', String(q.page))
+ return url.toString()
+}
+
+export async function directusList(collection: string, q: DirectusQuery = {}): Promise> {
+ 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
+}
+
+export async function directusOne(collection: string, q: DirectusQuery = {}): Promise {
+ const { data } = await directusList(collection, { ...q, limit: 1 })
+ return data[0]
+}
+
+export function assetUrl(fileId: string | null | undefined, key?: string): string {
+ if (!fileId) return ''
+ const url = new URL(`${DIRECTUS_URL}/assets/${fileId}`)
+ if (key) url.searchParams.append('key', key)
+ return url.toString()
+}
diff --git a/frontend/src/lib/env.ts b/frontend/src/lib/env.ts
new file mode 100644
index 0000000..f3b5bce
--- /dev/null
+++ b/frontend/src/lib/env.ts
@@ -0,0 +1,6 @@
+export const DIRECTUS_URL = import.meta.env.VITE_DIRECTUS_API_URL as string
+export const GLOBALS_ID = import.meta.env.VITE_GLOBALS_ID as string
+
+if (!DIRECTUS_URL) {
+ throw new Error('VITE_DIRECTUS_API_URL is not set')
+}
diff --git a/frontend/src/lib/queries.ts b/frontend/src/lib/queries.ts
new file mode 100644
index 0000000..5457a54
--- /dev/null
+++ b/frontend/src/lib/queries.ts
@@ -0,0 +1,118 @@
+import { queryOptions } from '@tanstack/react-query'
+import { directusList, directusOne } from './directus'
+import { GLOBALS_ID } from './env'
+import type { Category, Globals, Menu, MenuRaw, PageEntity, Vendor, VendorListItem } from './types'
+
+export const globalsQuery = queryOptions({
+ queryKey: ['globals'],
+ queryFn: async (): Promise => {
+ const item = await directusOne('globals', { fields: ['*'] })
+ if (!item) throw new Error('globals not found')
+ return item
+ },
+ 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