Initial commit

This commit is contained in:
allagroup
2026-04-17 07:21:17 +00:00
commit fe38508201
470 changed files with 74105 additions and 0 deletions

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.