feat: initial commit

This commit is contained in:
2026-04-10 16:50:03 +07:00
parent 071b1f1515
commit 1e8d6a9b19
157 changed files with 29900 additions and 122 deletions

View File

@@ -0,0 +1,615 @@
---
name: kiranism-shadcn-dashboard
description: |
Guide for building features, pages, tables, forms, themes, and navigation in this Next.js 16 shadcn dashboard template. Use this skill whenever the user wants to add a new page, create a feature module, build a data table, add a form, configure navigation items, add a theme, set up RBAC access control, or work with the dashboard's patterns and conventions. Also triggers when adding routes under /dashboard, working with Clerk auth/orgs/billing, creating mock APIs, or modifying the sidebar. Even if the user doesn't mention "dashboard" explicitly — if they're adding UI, pages, or features to this project, use this skill.
---
# Dashboard Development Guide
This skill encodes the exact patterns and conventions used in this Next.js 16 + shadcn/ui admin dashboard template.
## Quick Reference: What Goes Where
| Task | Location |
| --------------------- | --------------------------------------- |
| New page | `src/app/dashboard/<name>/page.tsx` |
| New feature module | `src/features/<name>/` |
| Feature components | `src/features/<name>/components/` |
| API types | `src/features/<name>/api/types.ts` |
| Service layer | `src/features/<name>/api/service.ts` |
| Query options | `src/features/<name>/api/queries.ts` |
| Mutation options | `src/features/<name>/api/mutations.ts` |
| Zod schemas | `src/features/<name>/schemas/<name>.ts` |
| Filter/select options | `src/features/<name>/constants/` |
| Nav config | `src/config/nav-config.ts` |
| Types | `src/types/index.ts` |
| Mock data | `src/constants/mock-api-<name>.ts` |
| Search params | `src/lib/searchparams.ts` |
| Query client | `src/lib/query-client.ts` |
| Theme CSS | `src/styles/themes/<name>.css` |
| Theme registry | `src/components/themes/theme.config.ts` |
| Custom hook | `src/hooks/` |
| Icons registry | `src/components/icons.tsx` |
---
## Adding a New Feature (End-to-End)
When a user asks to add a feature (e.g., "add an orders page"), follow these steps in order. Each step below shows the minimal pattern — see reference files for full templates.
### Step 1: Mock API (`src/constants/mock-api-<name>.ts`)
See [references/mock-api-guide.md](references/mock-api-guide.md) for the complete template. Key structure:
```tsx
import { faker } from '@faker-js/faker';
import { matchSorter } from 'match-sorter';
import { delay } from './mock-api';
export type Order = {
id: number;
customer: string;
status: string;
total: number;
created_at: string;
updated_at: string;
};
export const fakeOrders = {
records: [] as Order[],
initialize() {
/* generate with faker */
},
async getOrders({ page, limit, search, sort }) {
/* filter, sort, paginate, return { items, total_items } */
},
async getOrderById(id: number) {
/* find by id */
},
async createOrder(data) {
/* push to records */
},
async updateOrder(id, data) {
/* merge into record */
},
async deleteOrder(id) {
/* filter out */
}
};
fakeOrders.initialize();
```
Every method should call `await delay(800)` to simulate network latency. Use `matchSorter` for search. Return `{ items, total_items }` from list methods.
### Step 2: API Layer (`src/features/<name>/api/`)
Each feature has 4 API files: **types****service****queries****mutations**.
**Types** (`api/types.ts`) — re-export the entity type from mock API, plus filter/response/payload types:
```tsx
export type { Order } from '@/constants/mock-api-orders';
export type OrderFilters = { page?: number; limit?: number; search?: string; sort?: string };
export type OrdersResponse = { items: Order[]; total_items: number };
export type OrderMutationPayload = { customer: string; status: string; total: number };
```
**Service** (`api/service.ts`) — data access layer. One exported function per operation:
```tsx
import { fakeOrders } from '@/constants/mock-api-orders';
import type { OrderFilters, OrdersResponse, OrderMutationPayload } from './types';
export async function getOrders(filters: OrderFilters): Promise<OrdersResponse> {
return fakeOrders.getOrders(filters);
}
export async function getOrderById(id: number) {
return fakeOrders.getOrderById(id);
}
export async function createOrder(data: OrderMutationPayload) {
return fakeOrders.createOrder(data);
}
export async function updateOrder(id: number, data: OrderMutationPayload) {
return fakeOrders.updateOrder(id, data);
}
export async function deleteOrder(id: number) {
return fakeOrders.deleteOrder(id);
}
```
**Queries** (`api/queries.ts`) — query key factory + query options:
```tsx
import { queryOptions } from '@tanstack/react-query';
import { getOrders, getOrderById } from './service';
import type { Order, OrderFilters } from './types';
export type { Order };
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)
});
export const orderByIdOptions = (id: number) =>
queryOptions({
queryKey: orderKeys.detail(id),
queryFn: () => getOrderById(id)
});
```
**Mutations** (`api/mutations.ts`) — use `mutationOptions` + `getQueryClient()` (not custom hooks with `useQueryClient()`):
```tsx
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { createOrder, updateOrder, deleteOrder } from './service';
import { orderKeys } from './queries';
import type { OrderMutationPayload } from './types';
export const createOrderMutation = mutationOptions({
mutationFn: (data: OrderMutationPayload) => createOrder(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: orderKeys.all });
}
});
export const updateOrderMutation = mutationOptions({
mutationFn: ({ id, values }: { id: number; values: OrderMutationPayload }) =>
updateOrder(id, values),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: orderKeys.all });
}
});
export const deleteOrderMutation = mutationOptions({
mutationFn: (id: number) => deleteOrder(id),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: orderKeys.all });
}
});
```
`mutationOptions` is the right abstraction because it works outside React (event handlers, tests, utilities), composes via spread at the call site, and uses `getQueryClient()` which handles both SSR (fresh per request) and client (singleton) correctly. See [references/query-abstractions.md](references/query-abstractions.md) for the full rationale.
### Step 3: Zod Schema (`src/features/<name>/schemas/<name>.ts`)
```tsx
import { z } from 'zod';
export const orderSchema = z.object({
customer: z.string().min(2, 'Customer name must be at least 2 characters'),
status: z.string().min(1, 'Please select a status'),
total: z.number({ message: 'Total is required' })
});
export type OrderFormValues = z.infer<typeof orderSchema>;
```
### Step 4: Feature Components
Create `src/features/<name>/components/` with:
**Listing page** (server component — `<name>-listing.tsx`):
```tsx
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import { ordersQueryOptions } from '../api/queries';
import { OrderTable, OrderTableSkeleton } from './orders-table';
import { Suspense } from 'react';
export default function OrderListingPage() {
const page = searchParamsCache.get('page');
const search = searchParamsCache.get('name');
const pageLimit = searchParamsCache.get('perPage');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit: pageLimit,
...(search && { search }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(ordersQueryOptions(filters));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<OrderTableSkeleton />}>
<OrderTable />
</Suspense>
</HydrationBoundary>
);
}
```
**Table + skeleton** (client component — `orders-table/index.tsx`):
```tsx
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { getSortingStateParser } from '@/lib/parsers';
import { useDataTable } from '@/hooks/use-data-table';
import { DataTable } from '@/components/ui/table/data-table';
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
import { Skeleton } from '@/components/ui/skeleton';
import { ordersQueryOptions } from '../../api/queries';
import { columns } from './columns';
const columnIds = columns.map((c) => c.id).filter(Boolean) as string[];
export function OrderTable() {
const [params] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
});
const filters = {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
};
const { data } = useSuspenseQuery(ordersQueryOptions(filters));
const { table } = useDataTable({
data: data.items,
columns,
pageCount: Math.ceil(data.total_items / params.perPage),
shallow: true,
debounceMs: 500,
initialState: { columnPinning: { right: ['actions'] } }
});
return (
<DataTable table={table}>
<DataTableToolbar table={table} />
</DataTable>
);
}
export function OrderTableSkeleton() {
return (
<div className='space-y-4 p-4'>
<Skeleton className='h-10 w-full' />
<Skeleton className='h-96 w-full' />
</div>
);
}
```
**Column definitions** (`orders-table/columns.tsx`):
Each column needs `id`, `accessorKey` (or `accessorFn`), `header` with `DataTableColumnHeader`, and optionally `meta` for filtering + `enableColumnFilter: true`.
```tsx
export const columns: ColumnDef<Order>[] = [
{
id: 'customer',
accessorKey: 'customer',
header: ({ column }) => <DataTableColumnHeader column={column} title='Customer' />,
meta: { label: 'Customer', placeholder: 'Search...', variant: 'text', icon: Icons.text },
enableColumnFilter: true
},
{
id: 'status',
accessorKey: 'status',
header: ({ column }) => <DataTableColumnHeader column={column} title='Status' />,
cell: ({ cell }) => (
<Badge variant='outline' className='capitalize'>
{cell.getValue<string>()}
</Badge>
),
enableColumnFilter: true,
meta: { label: 'Status', variant: 'multiSelect', options: STATUS_OPTIONS }
},
{ id: 'actions', cell: ({ row }) => <CellAction data={row.original} /> }
];
```
Filter `meta.variant` options: `text`, `number`, `range`, `date`, `dateRange`, `select`, `multiSelect`, `boolean`. For multiSelect, provide `options: { value, label, icon? }[]`.
**Cell actions** (`orders-table/cell-action.tsx`):
Pattern: `DropdownMenu` with edit/delete items + `AlertModal` for delete confirmation + `useMutation` for the delete API call.
```tsx
import { deleteOrderMutation } from '../../api/mutations';
export const CellAction: React.FC<{ data: Order }> = ({ data }) => {
const [deleteOpen, setDeleteOpen] = useState(false);
const deleteMutation = useMutation({
...deleteOrderMutation,
onSuccess: () => {
toast.success('Deleted');
setDeleteOpen(false);
}
});
return (
<>
<AlertModal
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={() => deleteMutation.mutate(data.id)}
loading={deleteMutation.isPending}
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='h-8 w-8 p-0'>
<Icons.ellipsis className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => router.push(`/dashboard/orders/${data.id}`)}>
<Icons.edit className='mr-2 h-4 w-4' /> Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteOpen(true)}>
<Icons.trash className='mr-2 h-4 w-4' /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};
```
For **sheet-based editing** (like Users), replace `router.push` with opening a `<FormSheet>` — see the Forms section below.
### Step 5: Page Route (`src/app/dashboard/<name>/page.tsx`)
```tsx
import PageContainer from '@/components/layout/page-container';
import OrderListingPage from '@/features/orders/components/order-listing';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
export const metadata = { title: 'Dashboard: Orders' };
type PageProps = { searchParams: Promise<SearchParams> };
export default async function Page(props: PageProps) {
const searchParams = await props.searchParams;
searchParamsCache.parse(searchParams);
return (
<PageContainer
scrollable={false}
pageTitle='Orders'
pageDescription='Manage your orders.'
pageHeaderAction={/* Add button — Link or SheetTrigger */}
>
<OrderListingPage />
</PageContainer>
);
}
```
**PageContainer props**: `scrollable`, `pageTitle`, `pageDescription`, `pageHeaderAction` (React node for the top-right button), `infoContent` (help sidebar), `access` + `accessFallback` (RBAC gating).
**Detail/Edit page** (`src/app/dashboard/<name>/[id]/page.tsx`):
```tsx
import PageContainer from '@/components/layout/page-container';
import OrderViewPage from '@/features/orders/components/order-view-page';
export const metadata = { title: 'Dashboard: Order Details' };
type PageProps = { params: Promise<{ id: string }> };
export default async function Page(props: PageProps) {
const { id } = await props.params;
return (
<PageContainer scrollable pageTitle='Order Details'>
<OrderViewPage orderId={id} />
</PageContainer>
);
}
```
**View page component** (client — handles new vs edit):
```tsx
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { notFound } from 'next/navigation';
import { orderByIdOptions } from '../api/queries';
import OrderForm from './order-form';
export default function OrderViewPage({ orderId }: { orderId: string }) {
if (orderId === 'new') return <OrderForm initialData={null} pageTitle='Create Order' />;
const { data } = useSuspenseQuery(orderByIdOptions(Number(orderId)));
if (!data) notFound();
return <OrderForm initialData={data} pageTitle='Edit Order' />;
}
```
### Step 6: Search Params (`src/lib/searchparams.ts`)
Add any new filter keys. Existing params: `page`, `perPage`, `name`, `gender`, `category`, `role`, `sort`.
### Step 7: Navigation (`src/config/nav-config.ts`)
```tsx
{ title: 'Orders', url: '/dashboard/orders', icon: 'product', items: [] }
```
### Step 8: Icons (`src/components/icons.tsx`)
To register a new icon, import from `@tabler/icons-react` and add to the `Icons` object:
```tsx
import { IconShoppingCart } from '@tabler/icons-react';
export const Icons = { /* ...existing */ cart: IconShoppingCart };
```
Never import `@tabler/icons-react` anywhere else. Always use `Icons.keyName`.
**Existing icon keys** (partial): `dashboard`, `product`, `kanban`, `chat`, `forms`, `user`, `teams`, `billing`, `settings`, `add`, `edit`, `trash`, `search`, `check`, `close`, `clock`, `ellipsis`, `text`, `calendar`, `upload`, `spinner`, `chevronDown/Left/Right/Up`, `sun`, `moon`, `palette`, `pro`, `workspace`, `notification`.
---
## Forms
Forms use **TanStack Form + Zod** with `useAppForm` + `useFormFields<T>()` and `useMutation` for submission. See [references/forms-guide.md](references/forms-guide.md) for all field types, validation strategies, multi-step forms, and advanced patterns.
### Page Form (Create/Edit on a dedicated route)
The full pattern is shown in Steps 1-4 above. The key structure:
1. **Schema** — Zod schema + inferred type in `schemas/<name>.ts`
2. **Form component**`useAppForm({ defaultValues, validators: { onSubmit: schema }, onSubmit })` + `useFormFields<T>()` for typed fields
3. **Mutations**`useMutation({ ...createOrderMutation, onSuccess: () => { toast(); router.push() } })`, spread shared mutation options from `api/mutations.ts` and layer on UI callbacks
4. **View page** — client component that checks `id === 'new'` for create vs `useSuspenseQuery(byIdOptions)` for edit
### Sheet Form (Inline create/edit in a side panel)
For features where a separate page is overkill (like Users). The sheet manages open state; the form uses a `form` attribute to connect to the sheet footer's submit button.
```tsx
'use client';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
export function OrderFormSheet({
order,
open,
onOpenChange
}: {
order?: Order;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const isEdit = !!order;
const mutation = useMutation({
...(isEdit ? updateOrderMutation : createOrderMutation),
onSuccess: () => {
onOpenChange(false);
}
});
const form = useAppForm({
defaultValues: { customer: order?.customer ?? '' /* ... */ } as OrderFormValues,
validators: { onSubmit: orderSchema },
onSubmit: async ({ value }) => {
await mutation.mutateAsync(value);
}
});
const { FormTextField, FormSelectField } = useFormFields<OrderFormValues>();
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col'>
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit' : 'New'} Order</SheetTitle>
</SheetHeader>
<div className='flex-1 overflow-auto'>
<form.AppForm>
<form.Form id='order-sheet-form' className='space-y-4'>
<FormTextField name='customer' label='Customer' required />
<FormSelectField name='status' label='Status' required options={STATUS_OPTIONS} />
</form.Form>
</form.AppForm>
</div>
<SheetFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' form='order-sheet-form' disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : isEdit ? 'Update' : 'Create'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
```
For cell actions, add `const [editOpen, setEditOpen] = useState(false)` and render `<OrderFormSheet order={data} open={editOpen} onOpenChange={setEditOpen} />` with a `<DropdownMenuItem onClick={() => setEditOpen(true)}>`. For the page header "Add" button, create a trigger component that manages `open` state and renders the sheet.
**Available field components** from `useFormFields<T>()`: `FormTextField`, `FormTextareaField`, `FormSelectField`, `FormCheckboxField`, `FormSwitchField`, `FormRadioGroupField`, `FormSliderField`, `FormFileUploadField`.
---
## Data Fetching with React Query
The pattern is: server prefetch → HydrationBoundary → client useSuspenseQuery.
1. **Server**: `void queryClient.prefetchQuery(options)` — fire-and-forget during SSR streaming
2. **Client**: `useSuspenseQuery(options)` — picks up dehydrated data, suspends until resolved
3. **HydrationBoundary + dehydrate**: bridges server cache → client cache
4. **Suspense fallback**: skeleton shown while data streams
**Why `useSuspenseQuery` not `useQuery`:** `useQuery` doesn't integrate with Suspense — it shows loading even when data is prefetched. `useSuspenseQuery` picks up the dehydrated pending query. Once cached (within `staleTime: 60s`), subsequent visits are instant.
**Mutations** use `mutationOptions` + `getQueryClient()` in `mutations.ts`, composed via spread at the call site:
```tsx
// In mutations.ts — shared config
export const createOrderMutation = mutationOptions({
mutationFn: (data) => createOrder(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: orderKeys.all });
}
});
// In component — spread + layer UI callbacks
const mutation = useMutation({
...createOrderMutation,
onSuccess: () => toast.success('Created')
});
```
See [references/query-abstractions.md](references/query-abstractions.md) for why `mutationOptions`/`queryOptions` are the right abstraction over custom hooks.
---
## Navigation & RBAC
Configure in `src/config/nav-config.ts`. Items are filtered client-side in `src/hooks/use-nav.ts` using Clerk.
**Access control properties** on nav items:
- `requireOrg: boolean` — requires active Clerk organization
- `permission: string` — requires specific Clerk permission
- `role: string` — requires specific Clerk role
- `plan: string` — requires subscription plan (server-side)
- `feature: string` — requires feature flag (server-side)
Items without `access` are visible to everyone. All client-side checks are synchronous — no loading states.
---
## Themes
See [references/theming-guide.md](references/theming-guide.md) for the complete guide. Quick steps:
1. Create `src/styles/themes/<name>.css` with OKLCH color tokens + `@theme inline` block
2. Import in `src/styles/theme.css`
3. Register in `THEMES` array in `src/components/themes/theme.config.ts`
4. (Optional) Add Google Fonts in `src/components/themes/font.config.ts`
---
## Code Conventions
- **`cn()`** for class merging — never concatenate className strings
- **Server components by default** — only add `'use client'` when needed
- **React Query** — `void prefetchQuery()` on server + `useSuspenseQuery` on client
- **API layer** — `types.ts``service.ts``queries.ts``mutations.ts` per feature; `queryOptions`/`mutationOptions` as base abstractions (not custom hooks); `getQueryClient()` in mutations (not `useQueryClient()`); key factories (`entityKeys.all/list/detail`); components never import mock APIs directly
- **nuqs** — `searchParamsCache` on server, `useQueryStates` on client with `shallow: true`
- **Icons** — only from `@/components/icons`, never from `@tabler/icons-react` directly
- **Forms** — `useAppForm` + `useFormFields<T>()` from `@/components/ui/tanstack-form`
- **Page headers** — `PageContainer` props, never import `<Heading>` manually
- **Sort parser** — use `getSortingStateParser` from `@/lib/parsers` (same parser as `useDataTable`)
- **Formatting** — single quotes, JSX single quotes, no trailing comma, 2-space indent

View File

@@ -0,0 +1,420 @@
# 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:
```tsx
import { delay } from '@/constants/mock-api';
import { AreaGraph } from '@/features/overview/components/area-graph';
export default async function AreaStatsPage() {
await delay(2000); // Simulates API fetch
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,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,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.