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 => { + const { data } = await directusList('menus', { + fields: [ + '*', + 'menus_menu_items.sort', + 'menus_menu_items.menu_items_id.label', + 'menus_menu_items.menu_items_id.url', + 'menus_menu_items.menu_items_id.sort', + ], + limit: -1, + }) + return data.map((m) => ({ + id: m.id, + items: [...m.menus_menu_items].sort((a, b) => a.sort - b.sort).map((mm) => mm.menu_items_id), + })) + }, + staleTime: 5 * 60_000, +}) + +export const categoriesQuery = queryOptions({ + queryKey: ['categories', 'roots'], + queryFn: async (): Promise => { + const { data } = await directusList('categories', { + fields: ['slug', 'name', 'subcategories.slug', 'subcategories.name'], + sort: 'name', + limit: -1, + filter: { parent_id: { _null: true } }, + }) + return data + }, + staleTime: 5 * 60_000, +}) + +export const pageBySlugQuery = (slug: string) => + queryOptions({ + queryKey: ['page', slug], + queryFn: async (): Promise => { + return directusOne('pages', { + fields: ['slug', 'title', 'content'], + filter: { slug: { _eq: slug } }, + }) + }, + }) + +export type VendorsQueryParams = { + page: number + perPage: number + search: string + category: string +} + +export const vendorsListQuery = ({ page, perPage, search, category }: VendorsQueryParams) => + queryOptions({ + queryKey: ['vendors', { page, perPage, search, category }], + queryFn: async () => { + const filter = + search || category + ? { + _and: [ + category ? { categories: { categories_id: { slug: { _eq: category } } } } : {}, + search + ? { + _or: (['name', 'description', 'long_description', 'city'] as const).map((f) => ({ + [f]: { _contains: search }, + })), + } + : {}, + ], + } + : undefined + + return directusList('vendors', { + fields: ['*'], + limit: perPage, + page, + sort: 'name', + meta: ['filter_count'], + filter, + }) + }, + placeholderData: (prev) => prev, + }) + +export const vendorBySlugQuery = (slug: string) => + queryOptions({ + queryKey: ['vendor', slug], + queryFn: async (): Promise => { + return directusOne('vendors', { + fields: [ + '*', + 'categories.categories_id.slug', + 'categories.categories_id.name', + 'categories.categories_id.parent_id', + ], + filter: { slug: { _eq: slug } }, + }) + }, + }) diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..6d3c679 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,65 @@ +export type Globals = { + id: string + site_name: string + meta_title: string + meta_description: string + copyright: string +} + +export type MenuItem = { + label: string + url: string | null + sort: number +} + +export type MenuRaw = { + id: string + menus_menu_items: Array<{ + sort: number + menu_items_id: MenuItem + }> +} + +export type Menu = { + id: string + items: MenuItem[] +} + +export type PageEntity = { + slug: string + title: string + content: string +} + +export type Category = { + slug: string + name: string + parent_id: string | null + subcategories?: Category[] +} + +export type VendorCategoryJoin = { + categories_id: Category +} + +export type VendorListItem = { + id: string + slug: string + name: string + description: string | null + logo: string | null +} + +export type Vendor = VendorListItem & { + long_description: string | null + address_line_1: string | null + address_line_2: string | null + city: string | null + state: string | null + country: string | null + website: string | null + facebook: string | null + linkedin: string | null + twitter: string | null + categories: VendorCategoryJoin[] +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index fab1219..9d8d84b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,39 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import App from './App' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { HelmetProvider } from 'react-helmet-async' +import { routeTree } from '@generated/tanstack-router/routeTree.gen' import './index.css' +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + refetchOnWindowFocus: false, + }, + }, +}) + +const router = createRouter({ + routeTree, + context: { queryClient }, + defaultPreload: 'intent', + defaultPreloadStaleTime: 0, +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + createRoot(document.getElementById('root')!).render( - + + + + + , ) diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx new file mode 100644 index 0000000..8268e83 --- /dev/null +++ b/frontend/src/routes/__root.tsx @@ -0,0 +1,37 @@ +import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' +import type { QueryClient } from '@tanstack/react-query' +import { lazy, Suspense } from 'react' +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, +}) + +function RootComponent() { + return ( + <> + + + + + + + ) +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx new file mode 100644 index 0000000..9446c5b --- /dev/null +++ b/frontend/src/routes/index.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: () => ( +
+

Home

+

Routing placeholder — CMS page will render here.

+
+ ), +}) diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..d11b973 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_DIRECTUS_API_URL: string + readonly VITE_GLOBALS_ID: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +}