# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview Standalone admin dashboard for the **gig-platform** Laravel backend, consuming `/api/admin/v1/*`. React 19, TanStack Router (+ Query + Table), shadcn/ui on Radix, Tailwind v4, TypeScript, Vite. It is a **plain client-rendered SPA** — `index.html` → `main.tsx` → `RouterProvider`; `@tanstack/react-start` is not a dependency, so there is no SSR and no server functions. Package manager is **pnpm**. UI copy is hardcoded 简体中文 — `i18next`/`react-i18next` are in `package.json` but nothing imports them yet, so don't reach for translation hooks. The backend repo is expected as a sibling checkout at `../gig-platform` (see `pnpm gen:api`). ## Commands | Command | Purpose | | --- | --- | | `pnpm dev` | dev server on :3000 | | `pnpm build` / `pnpm preview` | production build / preview | | `pnpm typecheck` | `tsc --noEmit` | | `pnpm test` | vitest (all) | | `pnpm test src/api/client.test.ts` | single test file | | `pnpm generate-routes` | `tsr generate` — regenerate `routeTree.gen.ts` (the Vite plugin also does this on dev/build) | | `pnpm gen:api` | regenerate `src/api/types.gen.ts` from `../gig-platform/storage/app/api-docs/admin/openapi.yaml` | | `pnpm cf:deploy` | build + `wrangler deploy` | There is no lint script and no ESLint config — `typecheck` + `test` are the gate (and what CI runs, in `.github/workflows/ci.yml` and the mirrored `.gitea/workflows/ci.yml`). `pnpm gen:api` requires the backend to have generated its docs first: run `ddev artisan api-docs:generate` in `../gig-platform`. ## Architecture ### API layer (`src/api/`) `client.ts` is the only place that talks to the network. Everything else calls the thin `api.{get,post,put,patch,delete,upload,text}` helpers. - **Envelope**: the backend returns `{ success, data, message }`; paginated lists add `pagination` (`current_page`, `next_page`, `page_size`, `prev_page`, `total_count`, `total_pages`). Call sites type responses as `ApiResponse` / `PaginatedResponse` and read `.data`. - **Errors**: non-2xx throws `ApiError(status, code, message, errors?)` built from the backend's `{ success: false, code, message, data.errors }` payload. Catch `ApiError`, don't inspect raw responses. - **Auth**: Bearer token from the zustand store; on a 401 the client does a **single-flight** refresh (`POST /api/v1/auth/refresh-token` with `client_machine_name=admin-api-client` — note this is the *public* `v1` prefix, not `admin/v1`) and retries the request once, then clears the session. Login and refresh pass `anonymous: true` to opt out of this. - **Headers**: `X-Language-Locale: zh-CN` on every request. A `FormData` body deliberately gets **no** `Content-Type` — the browser must set it so the multipart boundary survives; setting it by hand makes the server see an empty upload (422 instead of 202). `fetch` gives no upload progress, so upload UIs stay indeterminate. - **Endpoint modules**: one file per backend domain in `src/api/modules/` (`studio.ts`, `exc.ts`, `categories.ts`, …), each with a `const BASE = '/api/admin/v1/{module}'` and exported `fetchX` / mutation functions. Add new endpoints there, not inline in components. - **Types**: `src/api/types.ts` is hand-written and authoritative. `types.gen.ts` is `@ts-nocheck` reference-only — scribe emits duplicate operationIds so the generated file doesn't typecheck. ### Auth & permissions (`src/auth/`) - `store.ts` — zustand + persist (localStorage) holding tokens, user, roles, permissions. `can(key)` / `useCan(key)` return true for anyone holding the key **or** the `super-admin` role. - `permissions.ts` — the `PERM` catalog mirroring the backend's `Modules/{Module}/app/Permissions/{Module}Permissions.php`. Keys are `admin:{area}:{action}`; a few operator-only ones use the `auth:` prefix (role management, db-studio, robot tokens) precisely because the `admin` role is synced to `admin:%` only. **When the backend adds a permission, add it to `PERM` here** — routes and nav reference the constants, never raw strings. - `requirePermission(PERM.X)` is the route `beforeLoad` guard. It redirects to the first accessible nav route only once a session is loaded; an empty store falls through and the API's 403 is the backstop. - `GET /auth/me` refreshes `meta.roles` / `meta.permissions` on mount and window focus (`src/routes/_authed.tsx`); an account with neither is logged out with a toast. ### Routing (`src/routes/`) File-based TanStack Router with `autoCodeSplitting`. `routeTree.gen.ts` is generated — never edit it. `_authed.tsx` is the authenticated shell (sidebar + header + ``); everything under `_authed/` is behind the session check. Public routes: `login.tsx`, `api-docs.tsx`. Adding a page means three coordinated edits: the route file (with its `requirePermission` guard), an entry in `NAV_GROUPS` in `src/components/layout/nav.ts` (with `permission`, so the sidebar self-filters), and the `PERM` key if it's new. ### List pages `useListPage(key, fetcher, filters)` in `src/lib/list-page.ts` is the standard pattern: it owns `page` / `page_size` state, keys the query on the serialized filters, uses `keepPreviousData`, and snaps back to page 1 when filters change. Pair it with `` (`src/components/data-table/`) and ``. Follow this rather than hand-rolling `useQuery` for a table. ### Layout of the rest - `src/components/ui/` — shadcn/ui primitives (`components.json` config); regenerate rather than hand-editing where possible - `src/components/` — shared app-level pieces (`data-table/`, `page-header`, `status-pill`, `confirm-dialog`, `show-once-token-dialog` for one-time token reveals) - `src/features/{domain}/` — domain-specific composed UI (detail sheets, edit sheets) pulled into route files - Path aliases: both `@/*` and `#/*` map to `./src/*`; existing code consistently uses `@/` ## Deployment Client-rendered Vite SPA served as static assets by a Cloudflare Worker (`wrangler.jsonc`, `not_found_handling: single-page-application`, so `dist/index.html` answers every client route). `cf:deploy` is just `vite build && wrangler deploy`. `VITE_API_BASE_URL` is **baked in at build time** — it is a CI variable, not a runtime setting. Pushes to `main` typecheck + test + build + deploy; `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` are repo secrets. ## Notes - `AGENTS.md` holds gotchas and non-obvious conventions that complement this file; everything below its `intent-skills` markers is generated by TanStack Intent — edit only above them. - Local backend defaults to `https://gig-platform.ddev.site` (`.env.example`). Seed a local admin from the backend repo with `ddev exec php artisan tinker storage/app/e2e-admin-seed.php` → `admin@gig.local / Admin@12345`.