generated from allagroup/nextjs-elysia-allaos
Initial commit
This commit is contained in:
@@ -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)`.
|
||||
@@ -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>;
|
||||
```
|
||||
@@ -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 })
|
||||
});
|
||||
```
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user