This commit is contained in:
phaichayon
2026-06-11 12:46:57 +07:00
parent 1993d88306
commit 7a390cf0df
720 changed files with 100919 additions and 0 deletions

View File

@@ -0,0 +1,384 @@
---
name: kiranism-shadcn-dashboard
description: |
Migration-oriented guide for building and evolving features in this Next.js 16 shadcn dashboard repo. Use this skill whenever the user wants to add dashboard pages, tables, forms, navigation, route handlers, or feature modules in this project, especially when moving from mock APIs to real persistence, replacing Clerk with Auth.js, adding Drizzle-backed CRUD, or implementing organization-aware RBAC. Even if the user only mentions a page, sidebar item, or "connect this to a real backend", use this skill because the repo's feature, auth, and data-layer conventions are tightly coupled.
---
# Dashboard Migration Guide
This skill is for evolving this starter from a demo template into an app-owned architecture.
Default target architecture:
- `Auth.js` for authentication and session access
- `Drizzle ORM` for persistence
- `src/app/api/**/route.ts` as the boundary for CRUD or BFF access
- app-owned `organization`, `membership`, `role`, and optional `permissions` data
Do not treat Clerk or `src/constants/mock-api*.ts` as the happy path. They are legacy template scaffolding that may still exist in the repo and should be migrated deliberately.
## Current Repo Reality
Before changing anything, ground the work in the repo's current state:
- `src/features/products/api/service.ts` and `src/features/users/api/service.ts` still call mock data
- `src/app/api/products/**` and `src/app/api/users/**` already exist as route-handler shells
- `src/hooks/use-nav.ts`, `src/proxy.ts`, and multiple pages/components are still coupled to Clerk
- there is no committed `auth.ts`, Drizzle schema, or shared app RBAC model yet
Work incrementally. Prefer a migration plan that:
1. introduces the auth shell
2. introduces the shared org and RBAC model
3. migrates one feature at a time from mock data to route handlers plus Drizzle
## Quick Reference
| Task | Default location |
| --- | --- |
| Auth.js config | `src/auth.ts` or project root `auth.ts` |
| Route protection | `src/proxy.ts` or `middleware.ts` |
| Drizzle schema | `src/db/schema/*.ts` or `src/db/schema.ts` |
| DB client | `src/lib/db.ts` or `src/db/index.ts` |
| Feature types | `src/features/<name>/api/types.ts` |
| Feature service | `src/features/<name>/api/service.ts` |
| Query options | `src/features/<name>/api/queries.ts` |
| Mutation options | `src/features/<name>/api/mutations.ts` |
| Route handlers | `src/app/api/<name>/route.ts` and `[id]/route.ts` |
| Feature components | `src/features/<name>/components/` |
| Dashboard page | `src/app/dashboard/<name>/page.tsx` |
| Nav config | `src/config/nav-config.ts` |
| RBAC types | `src/types/index.ts` plus app auth or membership types |
## Default Build Order For A Feature
When a user asks for a new feature or a migration from mocks, use this order unless the repo already has a stronger pattern:
### 1. Define persistence first
Create or extend Drizzle tables for:
- the feature entity
- organization ownership if data is tenant-scoped
- membership or permission tables if access depends on role
Keep entity rules close to the schema so later route handlers are straightforward.
### 2. Define feature API types
In `src/features/<name>/api/types.ts`, define:
- list filters
- list response shape
- detail response shape
- mutation payloads
Do not re-export types from mock files. Types should reflect the route contract or normalized entity shape.
Example:
```tsx
export interface Order {
id: number;
organizationId: string;
customerName: string;
status: 'pending' | 'paid' | 'cancelled';
totalCents: number;
createdAt: string;
updatedAt: string;
}
export interface OrderFilters {
page?: number;
limit?: number;
search?: string;
status?: string;
sort?: string;
}
export interface OrdersResponse {
items: Order[];
totalItems: number;
}
export interface OrderMutationPayload {
customerName: string;
status: Order['status'];
totalCents: number;
}
```
### 3. Implement route handlers as the app boundary
Use `src/app/api/<feature>/route.ts` for list and create, and `src/app/api/<feature>/[id]/route.ts` for detail, update, and delete.
Route handlers should:
- read Auth.js session or server auth helper
- resolve active `organization`
- verify `membership` or `role`
- read and write via Drizzle
- return JSON contracts that match `types.ts`
Keep them thin. Put reusable query logic in a server-side helper when it grows beyond one route.
Example shape:
```tsx
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
// Resolve active organization and role before querying.
const data = await listOrdersForOrganization({
db,
userId: session.user.id,
page,
limit
});
return NextResponse.json(data);
}
```
### 4. Implement feature service with `apiClient`
`service.ts` should call the local route handlers, not Drizzle directly and not mock stores.
```tsx
import { apiClient } from '@/lib/api-client';
import type {
OrderFilters,
OrdersResponse,
Order,
OrderMutationPayload
} from './types';
export async function getOrders(filters: OrderFilters): Promise<OrdersResponse> {
const searchParams = new URLSearchParams();
if (filters.page) searchParams.set('page', String(filters.page));
if (filters.limit) searchParams.set('limit', String(filters.limit));
if (filters.search) searchParams.set('search', filters.search);
if (filters.status) searchParams.set('status', filters.status);
if (filters.sort) searchParams.set('sort', filters.sort);
return apiClient<OrdersResponse>(`/orders?${searchParams.toString()}`);
}
export async function getOrderById(id: number): Promise<Order> {
return apiClient<Order>(`/orders/${id}`);
}
export async function createOrder(data: OrderMutationPayload): Promise<Order> {
return apiClient<Order>('/orders', { method: 'POST', body: JSON.stringify(data) });
}
```
### 5. Implement React Query options
Use `queryOptions` and `mutationOptions` as the base abstraction.
- `queries.ts` defines key factories and query options
- `mutations.ts` defines shared mutation options and cache invalidation
- components compose on top with `useSuspenseQuery` and `useMutation`
For every create, update, delete, assign, transfer, or return flow, treat post-mutation freshness as part of the feature contract, not as a nice-to-have. After a successful mutation, make sure all affected list views, detail views, timelines, counters, and dashboard summaries are synchronized immediately via `invalidateQueries`, `setQueryData`, optimistic updates, or a deliberate combination of them. Do not leave the user on stale data and do not rely on manual refresh as the normal recovery path.
See [references/query-abstractions.md](references/query-abstractions.md).
### 6. Build UI and pages
After the contracts are stable:
- build feature components under `src/features/<name>/components/`
- use server-prefetch plus `HydrationBoundary`
- add page route under `src/app/dashboard/<name>/page.tsx`
- wire navigation in `src/config/nav-config.ts`
## Auth.js And App-Owned RBAC
Use this vocabulary consistently:
- `user`: authenticated human actor
- `organization`: active tenant or workspace
- `membership`: relation between user and organization
- `role`: membership level such as `owner`, `admin`, or `member`
- `permissions`: optional fine-grained capabilities derived from role or stored explicitly
Do not use Clerk terms as if they still define the architecture.
### Auth boundary
The skill should guide agents to centralize auth in:
- `auth.ts` for Auth.js config and exported helpers
- route protection in `src/proxy.ts` or `middleware.ts`
- shared server helper for `requireSession`, `requireOrganization`, or `requireRole`
Example page protection:
```tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function Page() {
const session = await auth();
if (!session?.user) redirect('/auth/sign-in');
return <div>Protected dashboard page</div>;
}
```
### Navigation visibility
Navigation should be filtered from app-owned session or membership data, not Clerk hooks.
If a nav item requires access, model it around app semantics:
```tsx
access: {
requireOrganization: true,
role: 'admin'
}
```
If the repo still uses `requireOrg`, `role`, or `permission` in `src/types/index.ts`, treat that as a migration seam. Update the types and filtering logic only when the task reaches navigation or RBAC.
### Plan and feature gates
Do not promise a drop-in replacement for Clerk Billing.
When a page is gated by plan or feature:
- describe it as app-owned entitlement data
- keep the check server-side
- use placeholders until a billing provider exists
Good language: "read the organization's active plan from Drizzle and gate the page accordingly."
Bad language: "use Clerk Billing" or "use `<Protect plan='pro'>`".
## Feature Page Pattern
Use the repo's existing feature layout, but keep the new data path:
```tsx
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { Suspense } from 'react';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import { ordersQueryOptions } from '../api/queries';
import { OrdersTable, OrdersTableSkeleton } from './orders-table';
export default function OrderListingPage() {
const page = searchParamsCache.get('page');
const perPage = searchParamsCache.get('perPage');
const search = searchParamsCache.get('name');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit: perPage,
...(search && { search }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(ordersQueryOptions(filters));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<OrdersTableSkeleton />}>
<OrdersTable />
</Suspense>
</HydrationBoundary>
);
}
```
The page flow stays the same. The difference is the backing source:
- before: component -> service -> mock store
- target: component -> service -> route handler -> Drizzle
## Forms
Forms still use the existing project pattern:
- `useAppForm`
- `useFormFields<T>()`
- Zod schema for submit validation
- `useMutation({ ...sharedMutationOptions })`
See [references/forms-guide.md](references/forms-guide.md).
When you show form examples, submit to service functions that hit route handlers. Do not wire forms straight to mock data and do not bypass auth or org checks.
## Charts, Themes, And UI
Keep the existing UI conventions unless the task says otherwise:
- charts use Recharts plus `ChartContainer`
- themes use CSS variables and `theme.config.ts`
- page headers go through `PageContainer`
- icons come only from `@/components/icons`
For chart work, prefer data fetched through a real server path or a clearly labeled placeholder fetch. Avoid new examples that import `delay` from mock utilities as the default approach.
## Legacy Migration Notes
Use these notes when the user is touching existing template code:
- `src/constants/mock-api.ts` and `src/constants/mock-api-users.ts` are demo-only sources
- `src/features/*/api/service.ts` is the intended seam for migrating consumers off mock data
- `src/app/api/products/**` and `src/app/api/users/**` are good first route-handler migrations
- `src/hooks/use-nav.ts`, `src/proxy.ts`, `src/components/layout/providers.tsx`, and auth-facing pages are likely Clerk hot spots
Recommend migrating feature-by-feature instead of flipping everything at once.
## Reference Files
Open only the references needed for the task:
- [references/route-handlers-drizzle-guide.md](references/route-handlers-drizzle-guide.md) for API boundary, Drizzle flow, and CRUD migration
- [references/authjs-org-rbac-guide.md](references/authjs-org-rbac-guide.md) for Auth.js, org membership, and RBAC
- [references/migration-from-clerk-and-mocks.md](references/migration-from-clerk-and-mocks.md) when the task is explicitly a migration
- [references/forms-guide.md](references/forms-guide.md) for TanStack Form usage
- [references/query-abstractions.md](references/query-abstractions.md) for React Query patterns
- [references/theming-guide.md](references/theming-guide.md) for theme work
- [references/charts-guide.md](references/charts-guide.md) for overview pages and chart slots
`references/mock-api-guide.md` is now legacy context only. Do not load it unless the user explicitly asks how the old demo data works.
## Code Conventions
- Use server components by default
- Use `cn()` for class merging
- Keep feature code inside `src/features/<name>/`
- Keep the API layer split as `types.ts` -> `service.ts` -> `queries.ts` -> `mutations.ts`
- Prefer `useSuspenseQuery` with server prefetch
- Use `mutationOptions` with `getQueryClient()`
- Every CRUD mutation must define explicit post-success cache sync for affected queries
- Use `apiClient` for local route-handler calls
- Keep auth and role checks server-side for real protection
- Avoid direct imports from `src/constants/mock-api*`
- Avoid direct use of Clerk helpers or components in new examples
## What Good Guidance Looks Like
When this skill is working well, it should guide the agent toward:
- Auth.js session helpers instead of Clerk auth primitives
- Drizzle schema and route handlers instead of mock stores
- organization-aware server checks instead of client-only vendor hooks
- incremental migration steps that match the repo's real starting point

View File

@@ -0,0 +1,35 @@
{
"skill_name": "kiranism-shadcn-dashboard",
"evals": [
{
"id": 1,
"prompt": "Migrate the products feature in this dashboard starter away from the mock API. Keep the existing React Query and table flow, but make the default architecture Drizzle-backed route handlers under src/app/api/products and a service.ts that calls those local endpoints.",
"expected_output": "Guidance defaults to route handlers plus Drizzle, keeps the feature API layering, and treats mock data as legacy to be replaced.",
"files": [
"src/features/products/api/service.ts",
"src/app/api/products/route.ts",
"src/app/api/products/[id]/route.ts"
]
},
{
"id": 2,
"prompt": "Replace the Clerk-based protection on a dashboard page with Auth.js session checks and an app-owned organization membership model. I need the plan to cover middleware or protected routes, page-level guards, and how nav visibility should work without useOrganization/useUser.",
"expected_output": "Guidance defaults to Auth.js, explains user-organization-membership-role vocabulary, and avoids Clerk-specific hooks or components.",
"files": [
"src/proxy.ts",
"src/hooks/use-nav.ts",
"src/components/layout/providers.tsx"
]
},
{
"id": 3,
"prompt": "Add a new orders page to this repo. I want it built the way the project should look after migration: Drizzle schema first, route handlers for CRUD, feature api/types-service-queries-mutations, and dashboard UI with table filters.",
"expected_output": "Guidance uses the new build order from schema to UI, includes route handlers and Auth.js-aware server checks, and does not recommend mock-api files.",
"files": [
"src/app/dashboard/orders/page.tsx",
"src/features/orders/api/types.ts",
"src/app/api/orders/route.ts"
]
}
]
}

View File

@@ -0,0 +1,149 @@
# Auth.js + Organization RBAC Guide
Use this guide when the task involves authentication, session-aware pages, or replacing Clerk-coupled access control.
## Target model
This repo should evolve toward an app-owned model:
- `user`: authenticated person
- `organization`: active tenant or workspace
- `membership`: join record between user and organization
- `role`: coarse access level such as `owner`, `admin`, `member`
- `permissions`: optional fine-grained capabilities
Auth.js authenticates the user. Your app owns the organization and RBAC data.
## Auth boundary
Prefer a single auth boundary that exports:
- Auth.js config
- `auth()` session helper
- provider definitions
- callbacks that enrich the session with app-owned ids if needed
Typical locations:
- `auth.ts`
- `src/auth.ts`
Keep usage consistent across:
- route handlers
- protected server pages
- shared server utilities
## Protection patterns
### Protected page
```tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function BillingPage() {
const session = await auth();
if (!session?.user) {
redirect('/auth/sign-in');
}
return <div>Protected page</div>;
}
```
### Protected route handler
```tsx
import { NextResponse } from 'next/server';
import { auth } from '@/auth';
export async function POST() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
}
return NextResponse.json({ ok: true });
}
```
## Membership checks
Prefer a reusable server helper for membership resolution so page and API protection behave the same way.
Good responsibilities for a helper:
- require an authenticated session
- look up the active organization
- load the user's membership
- optionally enforce role or permissions
Example shape:
```tsx
export async function requireOrganizationAccess(options?: {
role?: 'owner' | 'admin' | 'member';
permission?: string;
}) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
const membership = await findActiveMembership(session.user.id);
if (!membership) throw new Error('Organization membership required');
if (options?.role && membership.role !== options.role) {
throw new Error('Forbidden');
}
return { session, membership };
}
```
## Navigation migration
The current repo uses Clerk hooks in `src/hooks/use-nav.ts`. Treat this as legacy.
Target behavior:
- nav visibility comes from app-owned session or membership data
- client rendering may consume a serialized access snapshot
- server pages and route handlers still enforce real access independently
If the task touches navigation, prefer one of these approaches:
1. compute filtered nav on the server and pass it into layout state
2. expose a lightweight session or membership snapshot to the client
3. keep client filtering, but source it from app-owned data rather than Clerk hooks
## Plan and feature gates
Do not map old Clerk Billing language directly into Auth.js.
Instead:
- store plan state on `organizations`, `subscriptions`, or entitlement tables
- expose a server helper such as `requirePlan('pro')`
- keep the page or API gate server-side
If there is no billing provider yet, document it as a placeholder and do not over-promise implementation detail.
## Migration advice for this repo
Clerk hot spots currently include:
- `src/proxy.ts`
- `src/hooks/use-nav.ts`
- `src/components/layout/providers.tsx`
- `src/components/layout/user-nav.tsx`
- `src/components/layout/app-sidebar.tsx`
- `src/app/dashboard/workspaces/**`
- `src/app/dashboard/billing/page.tsx`
Migrate in this order when possible:
1. auth config and protected-route shell
2. session-aware layout or providers
3. shared membership and RBAC utilities
4. page-level and route-level conversions
5. navigation and UI cleanup

View File

@@ -0,0 +1,419 @@
# Charts & Analytics Guide
## Table of Contents
1. [Overview Architecture](#overview-architecture)
2. [Parallel Routes Pattern](#parallel-routes-pattern)
3. [Chart Components](#chart-components)
4. [Stats Cards](#stats-cards)
5. [Skeleton Loading](#skeleton-loading)
6. [Adding a New Chart Section](#adding-a-new-chart-section)
---
## Overview Architecture
The analytics dashboard at `/dashboard/overview` uses **Next.js parallel routes** to load multiple chart sections independently. Each chart slot streams in as its data becomes ready — no waterfall, no blocking.
**File structure:**
```
src/app/dashboard/overview/
├── layout.tsx # Composes all slots into a grid
├── @area_stats/
│ ├── page.tsx # Async server component (fetches data)
│ ├── loading.tsx # Skeleton shown while streaming
│ └── error.tsx # Error boundary if fetch fails
├── @bar_stats/
│ ├── page.tsx
│ ├── loading.tsx
│ └── error.tsx
├── @pie_stats/
│ ├── page.tsx
│ ├── loading.tsx
│ └── error.tsx
└── @sales/
├── page.tsx
├── loading.tsx
└── error.tsx
src/features/overview/components/
├── area-graph.tsx # Client chart component
├── area-graph-skeleton.tsx # Matching skeleton
├── bar-graph.tsx
├── bar-graph-skeleton.tsx
├── pie-graph.tsx
├── pie-graph-skeleton.tsx
├── recent-sales.tsx
└── recent-sales-skeleton.tsx
```
---
## Parallel Routes Pattern
### Layout (`layout.tsx`)
The layout receives each parallel route as a prop and arranges them in a grid:
```tsx
export default function OverviewLayout({
sales,
pie_stats,
bar_stats,
area_stats
}: {
sales: React.ReactNode;
pie_stats: React.ReactNode;
bar_stats: React.ReactNode;
area_stats: React.ReactNode;
}) {
return (
<PageContainer pageTitle='Dashboard' pageDescription='Overview analytics.'>
{/* Stats cards row */}
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
<Card>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<CardTitle className='text-sm font-medium'>Total Revenue</CardTitle>
<Icons.billing className='h-4 w-4 text-muted-foreground' />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>$45,231.89</div>
<p className='text-xs text-muted-foreground'>+20.1% from last month</p>
</CardContent>
</Card>
{/* ...more stat cards */}
</div>
{/* Charts grid — each slot loads independently */}
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-7'>
<div className='col-span-4'>{area_stats}</div>
<div className='col-span-3'>{sales}</div>
<div className='col-span-4'>{bar_stats}</div>
<div className='col-span-3'>{pie_stats}</div>
</div>
</PageContainer>
);
}
```
### Slot Page (`@area_stats/page.tsx`)
Each slot is an async server component that fetches data then renders the chart. For production work, prefer a real server fetch or route-handler call. A short `setTimeout` demo is acceptable only as a clearly labeled placeholder:
```tsx
import { AreaGraph } from '@/features/overview/components/area-graph';
export default async function AreaStatsPage() {
await new Promise((resolve) => setTimeout(resolve, 2000)); // Placeholder fetch delay
return <AreaGraph />;
}
```
### Slot Loading (`@area_stats/loading.tsx`)
```tsx
import { AreaGraphSkeleton } from '@/features/overview/components/area-graph-skeleton';
export default function Loading() {
return <AreaGraphSkeleton />;
}
```
### Slot Error (`@area_stats/error.tsx`)
```tsx
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Icons } from '@/components/icons';
export default function AreaStatsError({ error }: { error: Error }) {
return (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load area stats: {error.message}</AlertDescription>
</Alert>
);
}
```
Each slot can fail independently without affecting others.
---
## Chart Components
All chart components are `'use client'` and use **Recharts** wrapped in shadcn's `ChartContainer`.
### Chart Config
Every chart defines a config object mapping data keys to labels and theme colors:
```tsx
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent
} from '@/components/ui/chart';
const chartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
mobile: { label: 'Mobile', color: 'var(--chart-2)' }
} satisfies ChartConfig;
```
Theme colors `--chart-1` through `--chart-5` are defined in each theme's CSS file and automatically adapt to light/dark mode.
### Area Chart Example
```tsx
'use client';
import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts';
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent
} from '@/components/ui/chart';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Icons } from '@/components/icons';
const chartData = [
{ month: 'January', desktop: 186, mobile: 80 },
{ month: 'February', desktop: 305, mobile: 200 }
// ...more months
];
const chartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
mobile: { label: 'Mobile', color: 'var(--chart-2)' }
} satisfies ChartConfig;
export function AreaGraph() {
return (
<Card className='@container/card'>
<CardHeader>
<CardTitle>Area Chart - Stacked</CardTitle>
<Badge variant='outline'>
<Icons.trendingUp className='mr-1 h-3 w-3' /> +12.5%
</Badge>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className='aspect-auto h-[250px] w-full'>
<AreaChart data={chartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey='month'
tickLine={false}
axisLine={false}
tickFormatter={(value) => value.slice(0, 3)}
/>
<ChartTooltip content={<ChartTooltipContent indicator='dot' />} />
<Area
dataKey='mobile'
type='natural'
fill='var(--color-mobile)'
stroke='var(--color-mobile)'
stackId='a'
/>
<Area
dataKey='desktop'
type='natural'
fill='var(--color-desktop)'
stroke='var(--color-desktop)'
stackId='a'
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}
```
### Bar Chart Pattern
Same structure, using `BarChart` + `Bar`:
```tsx
<ChartContainer config={chartConfig}>
<BarChart data={chartData}>
<CartesianGrid vertical={false} />
<XAxis dataKey='month' tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey='desktop' fill='var(--color-desktop)' radius={4} />
<Bar dataKey='mobile' fill='var(--color-mobile)' radius={4} />
</BarChart>
</ChartContainer>
```
### Pie/Donut Chart Pattern
```tsx
<ChartContainer config={chartConfig}>
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Pie data={chartData} dataKey='visitors' nameKey='browser' innerRadius={30}>
<LabelList dataKey='visitors' className='fill-background' />
</Pie>
</PieChart>
</ChartContainer>
```
---
## Stats Cards
Stats cards are simple server-rendered `Card` components at the top of the layout — no parallel routes needed since they render instantly:
```tsx
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>Total Revenue</CardTitle>
<Icons.billing className='h-4 w-4 text-muted-foreground' />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>$45,231.89</div>
<p className='text-xs text-muted-foreground'>+20.1% from last month</p>
</CardContent>
</Card>
```
For dynamic stats that need data fetching, wrap in their own Suspense boundary or parallel route slot.
---
## Skeleton Loading
Each chart has a matching skeleton component. Pattern:
```tsx
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function AreaGraphSkeleton() {
return (
<Card className='@container/card'>
<CardHeader>
<Skeleton className='h-5 w-[140px]' />
<Skeleton className='h-4 w-[80px]' />
</CardHeader>
<CardContent>
<Skeleton className='h-[250px] w-full rounded-md' />
</CardContent>
</Card>
);
}
```
Match the skeleton dimensions to the actual chart for smooth visual transitions.
---
## Adding a New Chart Section
To add a new chart (e.g., line chart for user growth):
### 1. Create the chart component
`src/features/overview/components/line-graph.tsx`:
```tsx
'use client';
import { Line, LineChart, CartesianGrid, XAxis } from 'recharts';
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent
} from '@/components/ui/chart';
const chartConfig = {
users: { label: 'Users', color: 'var(--chart-3)' }
} satisfies ChartConfig;
const chartData = [
/* monthly user data */
];
export function LineGraph() {
return (
<Card>
<CardHeader>
<CardTitle>User Growth</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className='aspect-auto h-[250px] w-full'>
<LineChart data={chartData}>
<CartesianGrid vertical={false} />
<XAxis dataKey='month' tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Line dataKey='users' type='monotone' stroke='var(--color-users)' strokeWidth={2} />
</LineChart>
</ChartContainer>
</CardContent>
</Card>
);
}
```
### 2. Create matching skeleton
`src/features/overview/components/line-graph-skeleton.tsx`
### 3. Create parallel route slot
```
src/app/dashboard/overview/@line_stats/
├── page.tsx → async, fetches data, returns <LineGraph />
├── loading.tsx → returns <LineGraphSkeleton />
├── error.tsx → error alert
└── default.tsx → return null (fallback when route doesn't match)
```
`default.tsx` is required for parallel routes — return `null` or a fallback:
```tsx
export default function Default() {
return null;
}
```
### 4. Add slot to layout
Update `src/app/dashboard/overview/layout.tsx`:
```tsx
export default function OverviewLayout({
sales,
pie_stats,
bar_stats,
area_stats,
line_stats // ← add new slot
}: {
/* ...types */
}) {
return (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-7'>
{/* existing charts */}
<div className='col-span-4'>{line_stats}</div>
</div>
);
}
```
### Available Recharts Components
Common chart types to use with `ChartContainer`:
- `AreaChart` + `Area` — filled area charts (stacked or standalone)
- `BarChart` + `Bar` — vertical/horizontal bars
- `LineChart` + `Line` — line/trend charts
- `PieChart` + `Pie` — pie/donut charts
- `RadarChart` + `Radar` — radar/spider charts
- `RadialBarChart` + `RadialBar` — radial progress bars
All support `ChartTooltip`, `ChartLegend`, and theme-aware colors via `var(--chart-N)`.

View File

@@ -0,0 +1,304 @@
# Forms Guide
## Table of Contents
1. [Architecture](#architecture)
2. [Field Types](#field-types)
3. [Usage Patterns](#usage-patterns)
4. [Validation Strategies](#validation-strategies)
5. [Sheet/Dialog Forms](#sheetdialog-forms)
6. [Multi-Step Forms](#multi-step-forms)
7. [Advanced Patterns](#advanced-patterns)
---
## Architecture
The form system is built on **TanStack Form + Zod** with a composable field layer.
**Key files:**
- `src/components/ui/tanstack-form.tsx` — exports `useAppForm`, `useFormFields<T>()`, composed fields
- `src/components/ui/form-context.tsx` — contexts, `createFormField`, structural components
- `src/components/forms/fields/*.tsx` — 8 field type implementations
**Key exports:**
```tsx
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
```
- `useAppForm(config)` — creates a form instance with `defaultValues`, `validators`, `onSubmit`
- `useFormFields<T>()` — returns all 8 typed field components with name autocomplete from `T`
- `form.AppForm` — context provider wrapper
- `form.Form``<form>` element that handles submit
- `form.SubmitButton` — auto-disabled when form is invalid or submitting
- `form.AppField` — low-level render prop for custom fields
---
## Field Types
All fields accept: `name`, `label`, `description`, `required`, `disabled`, `validators`, `listeners`, `className`.
| Component | Props | Notes |
| --------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------- |
| `FormTextField` | `type` (text/email/number/password/tel/url), `placeholder`, `min`, `max`, `step`, `maxLength` | For numbers use `type='number'` |
| `FormTextareaField` | `placeholder`, `rows`, `maxLength` | Multiline text |
| `FormSelectField` | `options: {value, label}[]`, `placeholder` | Single select dropdown |
| `FormCheckboxField` | `options?: {value, label}[]` | Single checkbox or multi-checkbox group |
| `FormSwitchField` | — | Toggle switch |
| `FormRadioGroupField` | `options: {value, label}[]`, `orientation` | Radio button group |
| `FormSliderField` | `min`, `max`, `step` | Range slider |
| `FormFileUploadField` | `maxSize`, `maxFiles`, `accept` | Drag-and-drop with preview |
---
## Usage Patterns
### Pattern 1: `useFormFields<T>()` (Recommended)
Type-safe field components with name autocomplete:
```tsx
const { FormTextField, FormSelectField } = useFormFields<OrderFormValues>();
<FormTextField name='customer' label='Customer' required placeholder='Name'
validators={{ onBlur: z.string().min(2) }} />
<FormSelectField name='status' label='Status' required options={STATUS_OPTIONS}
validators={{ onBlur: z.string().min(1) }} />
```
### Pattern 2: `form.AppField` render prop
Full control for custom field rendering:
```tsx
<form.AppField name='framework'>
{(field) => (
<field.FieldSet>
<field.Field>
<field.TextField label='Framework' />
</field.Field>
<field.FieldError />
</field.FieldSet>
)}
</form.AppField>
```
### Pattern 3: Direct import (no type safety)
For quick prototyping:
```tsx
import { FormTextField } from '@/components/ui/tanstack-form';
<FormTextField name='name' label='Name' />;
```
---
## Validation Strategies
### Field-level (recommended for UX)
```tsx
<FormTextField
name='email'
label='Email'
validators={{
onBlur: z.string().email('Invalid email') // Validates when field loses focus
}}
/>
```
### Form-level (catch-all on submit)
```tsx
const form = useAppForm({
validators: { onSubmit: orderSchema }, // Validates entire form on submit
onSubmit: async ({ value }) => {
/* ... */
}
});
```
### Async validation (server-side checks)
```tsx
<FormTextField
name='username'
label='Username'
validators={{
onChangeAsync: async ({ value }) => {
const exists = await checkUsername(value);
return exists ? 'Username taken' : undefined;
}
}}
asyncDebounceMs={500}
/>
```
### Linked field validation
For dependent fields (e.g., confirm password):
```tsx
<FormTextField
name='confirmPassword'
label='Confirm Password'
validators={{
onChangeListenTo: ['password'],
onChange: ({ value, fieldApi }) => {
const password = fieldApi.form.getFieldValue('password');
return value !== password ? 'Passwords must match' : undefined;
}
}}
/>
```
---
## Sheet/Dialog Forms
The key pattern for forms inside sheets or dialogs: give the `<form.Form>` an `id`, and use that `id` on the submit button's `form` attribute. This allows the submit button to live outside the form element (e.g., in `SheetFooter`).
```tsx
<form.AppForm>
<form.Form id='my-sheet-form' className='space-y-4'>
{/* fields */}
</form.Form>
</form.AppForm>;
{
/* In SheetFooter — button is outside the <form> but still submits it */
}
<SheetFooter>
<Button type='submit' form='my-sheet-form'>
Save
</Button>
</SheetFooter>;
```
On success, call `onOpenChange(false)` to close the sheet and `form.reset()` for create forms.
---
## Multi-Step Forms
Use `withFieldGroup` + `useAppForm` with `StepButton`:
```tsx
// Define field groups for each step
const Step1 = withFieldGroup({
fields: ['name', 'email'],
render: ({ form }) => {
const { FormTextField } = useFormFields<FormValues>();
return (
<>
<FormTextField name='name' label='Name' />
<FormTextField name='email' label='Email' />
<form.StepButton direction='next' label='Next' />
</>
);
}
});
const Step2 = withFieldGroup({
fields: ['address', 'city'],
render: ({ form }) => {
const { FormTextField } = useFormFields<FormValues>();
return (
<>
<FormTextField name='address' label='Address' />
<FormTextField name='city' label='City' />
<form.StepButton direction='prev' label='Back' />
<form.SubmitButton label='Submit' />
</>
);
}
});
```
Use the `useStepper` hook from `src/hooks/use-stepper.tsx` to manage step state.
---
## Advanced Patterns
### Nested objects (dot notation)
```tsx
<FormTextField name='address.street' label='Street' />
<FormTextField name='address.city' label='City' />
```
### Dynamic array rows
```tsx
<form.AppField name='items' mode='array'>
{(field) => (
<>
{field.state.value.map((_, i) => (
<form.AppField key={i} name={`items[${i}].name`}>
{(subField) => <subField.TextField label={`Item ${i + 1}`} />}
</form.AppField>
))}
<Button onClick={() => field.pushValue({ name: '' })}>Add Row</Button>
</>
)}
</form.AppField>
```
### Side effects with listeners
```tsx
<FormSelectField
name='country'
label='Country'
options={countryOptions}
listeners={{
onChange: ({ value }) => {
// Reset city when country changes
form.setFieldValue('city', '');
}
}}
/>
```
### Custom field with `form.AppField`
For fields not covered by the built-in 8 types:
```tsx
<form.AppField name='color'>
{(field) => (
<field.FieldSet>
<Label>Pick a color</Label>
<field.Field>
<input
type='color'
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</field.Field>
<field.FieldError />
</field.FieldSet>
)}
</form.AppField>
```
### Form-level errors
Display errors that apply to the whole form (e.g., server errors):
```tsx
import { FormErrors } from '@/components/ui/form-context';
<form.AppForm>
<form.Form>
<FormErrors /> {/* Renders form-level validation errors */}
{/* fields... */}
</form.Form>
</form.AppForm>;
```

View File

@@ -0,0 +1,81 @@
# Migration From Clerk And Mock APIs
Use this guide when the task is explicitly about converting this starter toward Auth.js and Drizzle.
## What exists today
This repo still contains two major demo-era seams:
- auth and workspace flows built around Clerk
- feature data flows built around `src/constants/mock-api*.ts`
The important thing is not to rewrite everything at once.
## Recommended migration order
### 1. Establish auth shell
Introduce Auth.js primitives first:
- auth config file
- protected route or middleware strategy
- session helper
- sign-in and sign-out surface
Do this before touching feature CRUD so server handlers have a stable way to identify the user.
### 2. Establish organization and membership model
Add app-owned tables and types for:
- organizations
- memberships
- roles
- optional permissions or entitlements
This replaces Clerk Organizations as the architectural source of truth.
### 3. Migrate one feature end-to-end
Pick a contained feature such as `products` or `users`.
Per feature:
- define Drizzle schema
- replace route-handler mocks with Drizzle queries
- point `service.ts` to local route handlers
- leave UI query usage stable where possible
### 4. Convert RBAC-aware UI
After shared membership utilities exist:
- migrate `use-nav.ts`
- migrate workspace or billing placeholders
- replace Clerk-only UI pieces with app-owned equivalents
## How to speak about legacy files
Use accurate migration language:
- "legacy mock source"
- "route-handler shell ready for Drizzle"
- "Clerk-coupled hotspot"
- "migration seam in `service.ts`"
Avoid language that suggests the old path is still the preferred architecture.
## Safe recommendations
Good:
- migrate feature-by-feature
- centralize auth checks
- centralize membership lookup
- keep HTTP contracts stable while swapping the backing store
Risky:
- editing every page before the auth shell exists
- mixing direct Drizzle calls into client-facing feature services
- rebuilding UI and auth simultaneously without preserving route contracts

View File

@@ -0,0 +1,255 @@
# Mock API Guide
## Table of Contents
1. [Structure](#structure)
2. [Full Template](#full-template)
3. [Key Patterns](#key-patterns)
4. [Integrating with React Query](#integrating-with-react-query)
---
## Structure
Each mock API file lives in `src/constants/mock-api-<name>.ts` and is a self-contained in-memory database. It uses:
- **faker** for generating sample data
- **match-sorter** for fuzzy search across fields
- **delay** (from `./mock-api`) to simulate network latency
The `delay` function is exported from `src/constants/mock-api.ts`:
```tsx
export async function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
```
---
## Full Template
```tsx
import { faker } from '@faker-js/faker';
import { matchSorter } from 'match-sorter';
import { delay } from './mock-api';
// 1. Define the entity type
export type Order = {
id: number;
customer: string;
email: string;
status: string;
total: number;
created_at: string;
updated_at: string;
};
// 2. Create the fake database object
export const fakeOrders = {
records: [] as Order[],
// 3. Initialize with faker data
initialize() {
const statuses = ['pending', 'processing', 'completed', 'cancelled'];
for (let i = 1; i <= 20; i++) {
this.records.push({
id: i,
customer: faker.person.fullName(),
email: faker.internet.email(),
status: faker.helpers.arrayElement(statuses),
total: parseFloat(faker.commerce.price({ min: 10, max: 500 })),
created_at: faker.date.between({ from: '2023-01-01', to: Date.now() }).toISOString(),
updated_at: faker.date.recent().toISOString()
});
}
},
// 4. Get all with optional search (used internally)
async getAll({ search }: { search?: string } = {}) {
let items = [...this.records];
if (search) {
items = matchSorter(items, search, {
keys: ['customer', 'email']
});
}
return items;
},
// 5. Paginated list with filtering and sorting
async getOrders(params: {
page?: number;
limit?: number;
search?: string;
statuses?: string;
sort?: string;
}) {
await delay(800);
const { page = 1, limit = 10, search, statuses, sort } = params;
let items = await this.getAll({ search });
// Filter by comma-separated values
if (statuses) {
const statusList = statuses.split('.');
items = items.filter((item) => statusList.includes(item.status));
}
// Sort by column
if (sort) {
const parsedSort = JSON.parse(sort) as { id: string; desc: boolean }[];
if (parsedSort.length > 0) {
const { id, desc } = parsedSort[0];
items.sort((a, b) => {
const aVal = a[id as keyof Order];
const bVal = b[id as keyof Order];
if (aVal < bVal) return desc ? 1 : -1;
if (aVal > bVal) return desc ? -1 : 1;
return 0;
});
}
}
// Paginate
const total_items = items.length;
items = items.slice((page - 1) * limit, page * limit);
return { items, total_items };
},
// 6. Get single record by ID
async getOrderById(id: number) {
await delay(800);
return this.records.find((r) => r.id === id) || null;
},
// 7. Create
async createOrder(data: Omit<Order, 'id' | 'created_at' | 'updated_at'>) {
await delay(800);
const newRecord: Order = {
...data,
id: this.records.length + 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
this.records.push(newRecord);
return newRecord;
},
// 8. Update
async updateOrder(id: number, data: Partial<Order>) {
await delay(800);
const idx = this.records.findIndex((r) => r.id === id);
if (idx === -1) return null;
this.records[idx] = {
...this.records[idx],
...data,
updated_at: new Date().toISOString()
};
return this.records[idx];
},
// 9. Delete
async deleteOrder(id: number) {
await delay(800);
this.records = this.records.filter((r) => r.id !== id);
return true;
}
};
// 10. Auto-initialize on import
fakeOrders.initialize();
```
---
## Key Patterns
### Search with match-sorter
Always specify which fields to search across:
```tsx
matchSorter(items, search, { keys: ['customer', 'email', 'status'] });
```
### Comma-separated filter values
For multi-select filters (roles, statuses), the URL param uses `.` as delimiter:
```tsx
if (statuses) {
const list = statuses.split('.');
items = items.filter((item) => list.includes(item.status));
}
```
### Computed column sorting
When a table has a computed column (e.g., combining first_name + last_name into "name"), handle it in the sort logic:
```tsx
if (id === 'name') {
const aName = `${a.first_name} ${a.last_name}`;
const bName = `${b.first_name} ${b.last_name}`;
return desc ? bName.localeCompare(aName) : aName.localeCompare(bName);
}
```
### Return shape
List methods must return `{ items, total_items }` (or `{ products, total }` etc. — match the query option expectations). The total is the count **before** pagination, used for `pageCount` calculation.
---
## Integrating with the API Layer
The mock API is only imported in `service.ts`. Queries and components import from the service and types files:
```
mock-api-orders.ts → api/service.ts → api/queries.ts → components
(data source) (data access) (key factory + (useSuspenseQuery
queryOptions) + useMutation)
```
**service.ts** imports from the mock API:
```tsx
import { fakeOrders } from '@/constants/mock-api-orders';
import type { OrderFilters, OrdersResponse } from './types';
export async function getOrders(filters: OrderFilters): Promise<OrdersResponse> {
return fakeOrders.getOrders(filters);
}
export async function createOrder(data: OrderMutationPayload) {
return fakeOrders.createOrder(data);
}
```
**queries.ts** imports from service, uses key factories:
```tsx
import { getOrders } from './service';
import type { OrderFilters } from './types';
export const orderKeys = {
all: ['orders'] as const,
list: (filters: OrderFilters) => [...orderKeys.all, 'list', filters] as const,
detail: (id: number) => [...orderKeys.all, 'detail', id] as const
};
export const ordersQueryOptions = (filters: OrderFilters) =>
queryOptions({ queryKey: orderKeys.list(filters), queryFn: () => getOrders(filters) });
```
**Mutations** in components use service functions + key factories:
```tsx
import { createOrder } from '../api/service';
import { orderKeys } from '../api/queries';
const mutation = useMutation({
mutationFn: (data) => createOrder(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: orderKeys.all })
});
```

View File

@@ -0,0 +1,153 @@
# TanStack Query Abstractions (v5)
The core insight: **`queryOptions` and `mutationOptions` are the right abstraction — not custom hooks.**
---
## Query Abstraction
### The Pattern
```ts
// queries/invoice.ts
import { queryOptions } from '@tanstack/react-query';
export function invoiceOptions(id: number) {
return queryOptions({
queryKey: ['invoice', id],
queryFn: () => fetchInvoice(id)
});
}
export function invoiceListOptions(filters: InvoiceFilters) {
return queryOptions({
queryKey: ['invoices', filters],
queryFn: () => fetchInvoices(filters),
staleTime: 30_000
});
}
```
### Usage — always compose at the call site
```ts
// basic
const { data } = useQuery(invoiceOptions(id));
// with suspense — same options, different hook
const { data } = useSuspenseQuery(invoiceOptions(id));
// with extra options spread on top — full type inference, no TS pain
const { data } = useQuery({
...invoiceOptions(id),
select: (invoice) => invoice.createdAt, // data infers as string | undefined
enabled: !!id
});
// prefetch in a route loader (works outside React — this is why hooks are wrong)
await queryClient.prefetchQuery(invoiceOptions(id));
// read from cache imperatively — queryKey is typed via DataTag symbol
const invoice = queryClient.getQueryData(invoiceOptions(id).queryKey);
// invalidate
queryClient.invalidateQueries({ queryKey: invoiceOptions(id).queryKey });
```
### Why NOT a custom hook
Custom hooks like `useInvoice(id)` have three critical problems:
1. **Hooks only work in components/hooks** — but queries are now used in route loaders, server prefetching, event handlers, and server components. `queryOptions` is just a plain function — works anywhere.
2. **They share logic, not configuration** — what you actually want to share is the `queryKey` + `queryFn` config. Hooks are the wrong primitive for that.
3. **They lock you to one hook** — you can't use `useInvoice()` with `useSuspenseQuery`, `useQueries`, or imperative `queryClient` methods.
### Why NOT `UseQueryOptions` type directly
```ts
// BAD — data becomes unknown
function useInvoice(id: number, options?: Partial<UseQueryOptions>) { ... }
// STILL BAD — select breaks with TS error
function useInvoice(id: number, options?: Partial<UseQueryOptions<Invoice>>) { ... }
// select: (invoice) => invoice.createdAt
// Error: Type 'string' is not assignable to type 'Invoice'
```
`queryOptions` solves this via a `DataTag` symbol on the queryKey — full inference, zero manual generics.
### Custom hooks are still fine on top
If a component always uses the same composition, a hook is fine — but build it _on top of_ `queryOptions`:
```ts
// OK — hook built on queryOptions
function useInvoice(id: number) {
return useQuery(invoiceOptions(id));
}
// OK — hook that adds per-feature defaults
function useInvoiceWithSuspense(id: number) {
return useSuspenseQuery(invoiceOptions(id));
}
```
---
## Mutation Abstraction
### The Pattern
```ts
// mutations/invoice.ts
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
export const createInvoiceMutation = mutationOptions({
mutationFn: (data: CreateInvoiceInput) => createInvoice(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: ['invoices'] });
}
});
export const updateInvoiceMutation = mutationOptions({
mutationFn: ({ id, ...data }: UpdateInvoiceInput) => updateInvoice(id, data),
onSuccess: (updated) => {
const qc = getQueryClient();
qc.setQueryData(invoiceOptions(updated.id).queryKey, updated);
qc.invalidateQueries({ queryKey: ['invoices'] });
}
});
```
> **Note on queryClient**: Import `getQueryClient()` directly — do NOT pass `queryClient` as a function argument. The `getQueryClient()` pattern handles both SSR (fresh per request) and client (singleton) correctly.
### Usage
```ts
// basic
const { mutate } = useMutation(createInvoiceMutation);
// composed — add per-usage callbacks on top
const { mutate } = useMutation({
...createInvoiceMutation,
onError: (err) => toast.error(err.message),
onSuccess: (data) => {
// this runs AFTER the shared onSuccess above
router.push(`/invoices/${data.id}`);
}
});
```
---
## Rules Summary
| Rule | Reason |
| ------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Use `queryOptions()` not custom hooks as the base abstraction | Works everywhere — loaders, server, imperative calls |
| Keep options factories lean — no extra config params | Best abstractions are not configurable |
| Compose extra options at the call site via spread | Full TS inference without manual generics |
| Import `getQueryClient()` in mutation files | Handles SSR/client correctly without prop drilling |
| Co-locate `queryKey` inside `queryOptions` | Typed key reuse in `invalidateQueries`, `setQueryData`, `getQueryData` |
| Custom hooks are fine — but built ON TOP of `queryOptions` | Hooks for component convenience, `queryOptions` for sharing config |

View File

@@ -0,0 +1,147 @@
# Route Handlers + Drizzle Guide
Use this guide when a feature should stop relying on mock data and start using the app's real server boundary.
## Default request flow
The preferred flow in this repo is:
`component -> service.ts -> /api route handler -> Drizzle -> database`
This keeps:
- React components UI-focused
- route handlers responsible for auth, org resolution, and HTTP behavior
- Drizzle access on the server only
Avoid these paths as defaults:
- `component -> mock-api`
- `component -> Drizzle directly`
- `client component -> external DB client`
## Build order
### 1. Schema first
Define tables and relations before writing React code.
Typical pieces:
- feature table such as `orders`
- optional `organizationId` foreign key
- audit columns such as `createdAt` and `updatedAt`
If the task includes org-aware access, make tenant ownership explicit in the schema.
### 2. API contract second
In `src/features/<feature>/api/types.ts`, define:
- entity shape used by the UI
- list filters
- list response
- detail response
- mutation payloads
Keep response types stable even if the schema is denormalized underneath.
### 3. Route handlers third
Implement:
- `src/app/api/<feature>/route.ts` for `GET` list and `POST` create
- `src/app/api/<feature>/[id]/route.ts` for `GET`, `PATCH` or `PUT`, and `DELETE`
Responsibilities:
- authenticate the request
- resolve active organization
- check membership or role
- parse request params and body
- query or mutate via Drizzle
- return consistent JSON
## Example list handler
```tsx
import { and, count, desc, eq, ilike } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { db } from '@/lib/db';
import { memberships, orders } from '@/db/schema';
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const search = searchParams.get('search') ?? '';
const offset = (page - 1) * limit;
const membership = await db.query.memberships.findFirst({
where: eq(memberships.userId, session.user.id)
});
if (!membership) {
return NextResponse.json({ message: 'Organization membership required' }, { status: 403 });
}
const whereClause = and(
eq(orders.organizationId, membership.organizationId),
search ? ilike(orders.customerName, `%${search}%`) : undefined
);
const [items, totalRows] = await Promise.all([
db.select().from(orders).where(whereClause).orderBy(desc(orders.createdAt)).limit(limit).offset(offset),
db.select({ value: count() }).from(orders).where(whereClause)
]);
return NextResponse.json({
items,
totalItems: totalRows[0]?.value ?? 0
});
}
```
## Service layer pattern
`service.ts` should call the local route handlers through `apiClient`.
```tsx
import { apiClient } from '@/lib/api-client';
import type { Order, OrderFilters, OrdersResponse, OrderMutationPayload } from './types';
export async function getOrders(filters: OrderFilters): Promise<OrdersResponse> {
const searchParams = new URLSearchParams();
if (filters.page) searchParams.set('page', String(filters.page));
if (filters.limit) searchParams.set('limit', String(filters.limit));
if (filters.search) searchParams.set('search', filters.search);
if (filters.status) searchParams.set('status', filters.status);
if (filters.sort) searchParams.set('sort', filters.sort);
return apiClient<OrdersResponse>(`/orders?${searchParams.toString()}`);
}
export async function createOrder(values: OrderMutationPayload): Promise<Order> {
return apiClient<Order>('/orders', {
method: 'POST',
body: JSON.stringify(values)
});
}
```
## Migration advice for this repo
If the task is migrating existing code:
- start with `products` or `users` because both already have route-handler shells
- swap `service.ts` away from `fakeProducts` or `fakeUsers`
- replace route-handler mock implementations with Drizzle queries
- keep `queries.ts` and UI mostly stable where possible
That minimizes UI churn and isolates the migration to the intended seam.

View File

@@ -0,0 +1,180 @@
# Theme Creation Guide
## Table of Contents
1. [Create Theme CSS](#1-create-theme-css)
2. [Import Theme](#2-import-theme)
3. [Register Theme](#3-register-theme)
4. [Add Custom Fonts](#4-add-custom-fonts-optional)
5. [Set as Default](#5-set-as-default-optional)
6. [Required Tokens](#required-tokens)
7. [Color Format Reference](#color-format-reference)
---
## 1. Create Theme CSS
Create `src/styles/themes/<name>.css`:
```css
/* Light mode */
[data-theme='your-theme'] {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(...);
--card-foreground: oklch(...);
--popover: oklch(...);
--popover-foreground: oklch(...);
--primary: oklch(...);
--primary-foreground: oklch(...);
--secondary: oklch(...);
--secondary-foreground: oklch(...);
--muted: oklch(...);
--muted-foreground: oklch(...);
--accent: oklch(...);
--accent-foreground: oklch(...);
--destructive: oklch(...);
--destructive-foreground: oklch(...);
--border: oklch(...);
--input: oklch(...);
--ring: oklch(...);
--chart-1: oklch(...);
--chart-2: oklch(...);
--chart-3: oklch(...);
--chart-4: oklch(...);
--chart-5: oklch(...);
--sidebar: oklch(...);
--sidebar-foreground: oklch(...);
--sidebar-primary: oklch(...);
--sidebar-primary-foreground: oklch(...);
--sidebar-accent: oklch(...);
--sidebar-accent-foreground: oklch(...);
--sidebar-border: oklch(...);
--sidebar-ring: oklch(...);
--font-sans: 'Font Name', sans-serif;
--font-mono: 'Mono Font', monospace;
--radius: 0.5rem;
--spacing: 0.25rem;
}
/* Dark mode */
[data-theme='your-theme'].dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
/* ... all tokens with dark values */
}
/* Tailwind integration (required) */
[data-theme='your-theme'] {
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
}
```
## 2. Import Theme
Add to `src/styles/theme.css`:
```css
@import './themes/your-theme.css';
```
## 3. Register Theme
Add to `THEMES` array in `src/components/themes/theme.config.ts`:
```typescript
{ name: 'Your Theme', value: 'your-theme' }
```
The `value` must exactly match the `data-theme` attribute in your CSS.
## 4. Add Custom Fonts (Optional)
Only if using a Google Font not already loaded.
In `src/components/themes/font.config.ts`:
```typescript
import { Your_Font } from 'next/font/google';
const fontYourName = Your_Font({
subsets: ['latin'],
weight: ['400', '500', '700'],
variable: '--font-your-name'
});
export const fontVariables = cn(
// ... existing fonts
fontYourName.variable
);
```
In your theme CSS, use the font's **display name** (not the CSS variable):
```css
--font-sans: 'Your Font', sans-serif;
```
## 5. Set as Default (Optional)
In `src/components/themes/theme.config.ts`:
```typescript
export const DEFAULT_THEME = 'your-theme';
```
## Required Tokens
Minimum required: `--background`, `--foreground`, `--card` & `--card-foreground`, `--popover` & `--popover-foreground`, `--primary` & `--primary-foreground`, `--secondary` & `--secondary-foreground`, `--muted` & `--muted-foreground`, `--accent` & `--accent-foreground`, `--destructive` & `--destructive-foreground`, `--border`, `--input`, `--ring`, `--radius`.
Optional: `--chart-*`, `--sidebar-*`, `--font-*`, `--shadow-*`, `--tracking-normal`, `--spacing`.
## Color Format Reference
OKLCH: `oklch(lightness chroma hue)`
- Lightness: 0-1 (0=black, 1=white)
- Chroma: 0+ (0=gray, higher=saturated)
- Hue: 0-360 (0=red, 120=green, 240=blue)
See `src/styles/themes/claude.css` for a complete example.