init
This commit is contained in:
384
.agents/skills/kiranism-shadcn-dashboard/SKILL.md
Normal file
384
.agents/skills/kiranism-shadcn-dashboard/SKILL.md
Normal file
@@ -0,0 +1,384 @@
|
||||
---
|
||||
name: kiranism-shadcn-dashboard
|
||||
description: |
|
||||
Migration-oriented guide for building and evolving features in this Next.js 16 shadcn dashboard repo. Use this skill whenever the user wants to add dashboard pages, tables, forms, navigation, route handlers, or feature modules in this project, especially when moving from mock APIs to real persistence, replacing Clerk with Auth.js, adding Drizzle-backed CRUD, or implementing organization-aware RBAC. Even if the user only mentions a page, sidebar item, or "connect this to a real backend", use this skill because the repo's feature, auth, and data-layer conventions are tightly coupled.
|
||||
---
|
||||
|
||||
# Dashboard Migration Guide
|
||||
|
||||
This skill is for evolving this starter from a demo template into an app-owned architecture.
|
||||
|
||||
Default target architecture:
|
||||
|
||||
- `Auth.js` for authentication and session access
|
||||
- `Drizzle ORM` for persistence
|
||||
- `src/app/api/**/route.ts` as the boundary for CRUD or BFF access
|
||||
- app-owned `organization`, `membership`, `role`, and optional `permissions` data
|
||||
|
||||
Do not treat Clerk or `src/constants/mock-api*.ts` as the happy path. They are legacy template scaffolding that may still exist in the repo and should be migrated deliberately.
|
||||
|
||||
## Current Repo Reality
|
||||
|
||||
Before changing anything, ground the work in the repo's current state:
|
||||
|
||||
- `src/features/products/api/service.ts` and `src/features/users/api/service.ts` still call mock data
|
||||
- `src/app/api/products/**` and `src/app/api/users/**` already exist as route-handler shells
|
||||
- `src/hooks/use-nav.ts`, `src/proxy.ts`, and multiple pages/components are still coupled to Clerk
|
||||
- there is no committed `auth.ts`, Drizzle schema, or shared app RBAC model yet
|
||||
|
||||
Work incrementally. Prefer a migration plan that:
|
||||
|
||||
1. introduces the auth shell
|
||||
2. introduces the shared org and RBAC model
|
||||
3. migrates one feature at a time from mock data to route handlers plus Drizzle
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Default location |
|
||||
| --- | --- |
|
||||
| Auth.js config | `src/auth.ts` or project root `auth.ts` |
|
||||
| Route protection | `src/proxy.ts` or `middleware.ts` |
|
||||
| Drizzle schema | `src/db/schema/*.ts` or `src/db/schema.ts` |
|
||||
| DB client | `src/lib/db.ts` or `src/db/index.ts` |
|
||||
| Feature types | `src/features/<name>/api/types.ts` |
|
||||
| Feature service | `src/features/<name>/api/service.ts` |
|
||||
| Query options | `src/features/<name>/api/queries.ts` |
|
||||
| Mutation options | `src/features/<name>/api/mutations.ts` |
|
||||
| Route handlers | `src/app/api/<name>/route.ts` and `[id]/route.ts` |
|
||||
| Feature components | `src/features/<name>/components/` |
|
||||
| Dashboard page | `src/app/dashboard/<name>/page.tsx` |
|
||||
| Nav config | `src/config/nav-config.ts` |
|
||||
| RBAC types | `src/types/index.ts` plus app auth or membership types |
|
||||
|
||||
## Default Build Order For A Feature
|
||||
|
||||
When a user asks for a new feature or a migration from mocks, use this order unless the repo already has a stronger pattern:
|
||||
|
||||
### 1. Define persistence first
|
||||
|
||||
Create or extend Drizzle tables for:
|
||||
|
||||
- the feature entity
|
||||
- organization ownership if data is tenant-scoped
|
||||
- membership or permission tables if access depends on role
|
||||
|
||||
Keep entity rules close to the schema so later route handlers are straightforward.
|
||||
|
||||
### 2. Define feature API types
|
||||
|
||||
In `src/features/<name>/api/types.ts`, define:
|
||||
|
||||
- list filters
|
||||
- list response shape
|
||||
- detail response shape
|
||||
- mutation payloads
|
||||
|
||||
Do not re-export types from mock files. Types should reflect the route contract or normalized entity shape.
|
||||
|
||||
Example:
|
||||
|
||||
```tsx
|
||||
export interface Order {
|
||||
id: number;
|
||||
organizationId: string;
|
||||
customerName: string;
|
||||
status: 'pending' | 'paid' | 'cancelled';
|
||||
totalCents: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OrderFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface OrdersResponse {
|
||||
items: Order[];
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
export interface OrderMutationPayload {
|
||||
customerName: string;
|
||||
status: Order['status'];
|
||||
totalCents: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Implement route handlers as the app boundary
|
||||
|
||||
Use `src/app/api/<feature>/route.ts` for list and create, and `src/app/api/<feature>/[id]/route.ts` for detail, update, and delete.
|
||||
|
||||
Route handlers should:
|
||||
|
||||
- read Auth.js session or server auth helper
|
||||
- resolve active `organization`
|
||||
- verify `membership` or `role`
|
||||
- read and write via Drizzle
|
||||
- return JSON contracts that match `types.ts`
|
||||
|
||||
Keep them thin. Put reusable query logic in a server-side helper when it grows beyond one route.
|
||||
|
||||
Example shape:
|
||||
|
||||
```tsx
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const page = Number(searchParams.get('page') ?? 1);
|
||||
const limit = Number(searchParams.get('limit') ?? 10);
|
||||
|
||||
// Resolve active organization and role before querying.
|
||||
const data = await listOrdersForOrganization({
|
||||
db,
|
||||
userId: session.user.id,
|
||||
page,
|
||||
limit
|
||||
});
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Implement feature service with `apiClient`
|
||||
|
||||
`service.ts` should call the local route handlers, not Drizzle directly and not mock stores.
|
||||
|
||||
```tsx
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
OrderFilters,
|
||||
OrdersResponse,
|
||||
Order,
|
||||
OrderMutationPayload
|
||||
} from './types';
|
||||
|
||||
export async function getOrders(filters: OrderFilters): Promise<OrdersResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (filters.page) searchParams.set('page', String(filters.page));
|
||||
if (filters.limit) searchParams.set('limit', String(filters.limit));
|
||||
if (filters.search) searchParams.set('search', filters.search);
|
||||
if (filters.status) searchParams.set('status', filters.status);
|
||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||
|
||||
return apiClient<OrdersResponse>(`/orders?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export async function getOrderById(id: number): Promise<Order> {
|
||||
return apiClient<Order>(`/orders/${id}`);
|
||||
}
|
||||
|
||||
export async function createOrder(data: OrderMutationPayload): Promise<Order> {
|
||||
return apiClient<Order>('/orders', { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Implement React Query options
|
||||
|
||||
Use `queryOptions` and `mutationOptions` as the base abstraction.
|
||||
|
||||
- `queries.ts` defines key factories and query options
|
||||
- `mutations.ts` defines shared mutation options and cache invalidation
|
||||
- components compose on top with `useSuspenseQuery` and `useMutation`
|
||||
|
||||
For every create, update, delete, assign, transfer, or return flow, treat post-mutation freshness as part of the feature contract, not as a nice-to-have. After a successful mutation, make sure all affected list views, detail views, timelines, counters, and dashboard summaries are synchronized immediately via `invalidateQueries`, `setQueryData`, optimistic updates, or a deliberate combination of them. Do not leave the user on stale data and do not rely on manual refresh as the normal recovery path.
|
||||
|
||||
See [references/query-abstractions.md](references/query-abstractions.md).
|
||||
|
||||
### 6. Build UI and pages
|
||||
|
||||
After the contracts are stable:
|
||||
|
||||
- build feature components under `src/features/<name>/components/`
|
||||
- use server-prefetch plus `HydrationBoundary`
|
||||
- add page route under `src/app/dashboard/<name>/page.tsx`
|
||||
- wire navigation in `src/config/nav-config.ts`
|
||||
|
||||
## Auth.js And App-Owned RBAC
|
||||
|
||||
Use this vocabulary consistently:
|
||||
|
||||
- `user`: authenticated human actor
|
||||
- `organization`: active tenant or workspace
|
||||
- `membership`: relation between user and organization
|
||||
- `role`: membership level such as `owner`, `admin`, or `member`
|
||||
- `permissions`: optional fine-grained capabilities derived from role or stored explicitly
|
||||
|
||||
Do not use Clerk terms as if they still define the architecture.
|
||||
|
||||
### Auth boundary
|
||||
|
||||
The skill should guide agents to centralize auth in:
|
||||
|
||||
- `auth.ts` for Auth.js config and exported helpers
|
||||
- route protection in `src/proxy.ts` or `middleware.ts`
|
||||
- shared server helper for `requireSession`, `requireOrganization`, or `requireRole`
|
||||
|
||||
Example page protection:
|
||||
|
||||
```tsx
|
||||
import { auth } from '@/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function Page() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect('/auth/sign-in');
|
||||
|
||||
return <div>Protected dashboard page</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation visibility
|
||||
|
||||
Navigation should be filtered from app-owned session or membership data, not Clerk hooks.
|
||||
|
||||
If a nav item requires access, model it around app semantics:
|
||||
|
||||
```tsx
|
||||
access: {
|
||||
requireOrganization: true,
|
||||
role: 'admin'
|
||||
}
|
||||
```
|
||||
|
||||
If the repo still uses `requireOrg`, `role`, or `permission` in `src/types/index.ts`, treat that as a migration seam. Update the types and filtering logic only when the task reaches navigation or RBAC.
|
||||
|
||||
### Plan and feature gates
|
||||
|
||||
Do not promise a drop-in replacement for Clerk Billing.
|
||||
|
||||
When a page is gated by plan or feature:
|
||||
|
||||
- describe it as app-owned entitlement data
|
||||
- keep the check server-side
|
||||
- use placeholders until a billing provider exists
|
||||
|
||||
Good language: "read the organization's active plan from Drizzle and gate the page accordingly."
|
||||
|
||||
Bad language: "use Clerk Billing" or "use `<Protect plan='pro'>`".
|
||||
|
||||
## Feature Page Pattern
|
||||
|
||||
Use the repo's existing feature layout, but keep the new data path:
|
||||
|
||||
```tsx
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { Suspense } from 'react';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import { ordersQueryOptions } from '../api/queries';
|
||||
import { OrdersTable, OrdersTableSkeleton } from './orders-table';
|
||||
|
||||
export default function OrderListingPage() {
|
||||
const page = searchParamsCache.get('page');
|
||||
const perPage = searchParamsCache.get('perPage');
|
||||
const search = searchParamsCache.get('name');
|
||||
const sort = searchParamsCache.get('sort');
|
||||
|
||||
const filters = {
|
||||
page,
|
||||
limit: perPage,
|
||||
...(search && { search }),
|
||||
...(sort && { sort })
|
||||
};
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
void queryClient.prefetchQuery(ordersQueryOptions(filters));
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<Suspense fallback={<OrdersTableSkeleton />}>
|
||||
<OrdersTable />
|
||||
</Suspense>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The page flow stays the same. The difference is the backing source:
|
||||
|
||||
- before: component -> service -> mock store
|
||||
- target: component -> service -> route handler -> Drizzle
|
||||
|
||||
## Forms
|
||||
|
||||
Forms still use the existing project pattern:
|
||||
|
||||
- `useAppForm`
|
||||
- `useFormFields<T>()`
|
||||
- Zod schema for submit validation
|
||||
- `useMutation({ ...sharedMutationOptions })`
|
||||
|
||||
See [references/forms-guide.md](references/forms-guide.md).
|
||||
|
||||
When you show form examples, submit to service functions that hit route handlers. Do not wire forms straight to mock data and do not bypass auth or org checks.
|
||||
|
||||
## Charts, Themes, And UI
|
||||
|
||||
Keep the existing UI conventions unless the task says otherwise:
|
||||
|
||||
- charts use Recharts plus `ChartContainer`
|
||||
- themes use CSS variables and `theme.config.ts`
|
||||
- page headers go through `PageContainer`
|
||||
- icons come only from `@/components/icons`
|
||||
|
||||
For chart work, prefer data fetched through a real server path or a clearly labeled placeholder fetch. Avoid new examples that import `delay` from mock utilities as the default approach.
|
||||
|
||||
## Legacy Migration Notes
|
||||
|
||||
Use these notes when the user is touching existing template code:
|
||||
|
||||
- `src/constants/mock-api.ts` and `src/constants/mock-api-users.ts` are demo-only sources
|
||||
- `src/features/*/api/service.ts` is the intended seam for migrating consumers off mock data
|
||||
- `src/app/api/products/**` and `src/app/api/users/**` are good first route-handler migrations
|
||||
- `src/hooks/use-nav.ts`, `src/proxy.ts`, `src/components/layout/providers.tsx`, and auth-facing pages are likely Clerk hot spots
|
||||
|
||||
Recommend migrating feature-by-feature instead of flipping everything at once.
|
||||
|
||||
## Reference Files
|
||||
|
||||
Open only the references needed for the task:
|
||||
|
||||
- [references/route-handlers-drizzle-guide.md](references/route-handlers-drizzle-guide.md) for API boundary, Drizzle flow, and CRUD migration
|
||||
- [references/authjs-org-rbac-guide.md](references/authjs-org-rbac-guide.md) for Auth.js, org membership, and RBAC
|
||||
- [references/migration-from-clerk-and-mocks.md](references/migration-from-clerk-and-mocks.md) when the task is explicitly a migration
|
||||
- [references/forms-guide.md](references/forms-guide.md) for TanStack Form usage
|
||||
- [references/query-abstractions.md](references/query-abstractions.md) for React Query patterns
|
||||
- [references/theming-guide.md](references/theming-guide.md) for theme work
|
||||
- [references/charts-guide.md](references/charts-guide.md) for overview pages and chart slots
|
||||
|
||||
`references/mock-api-guide.md` is now legacy context only. Do not load it unless the user explicitly asks how the old demo data works.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Use server components by default
|
||||
- Use `cn()` for class merging
|
||||
- Keep feature code inside `src/features/<name>/`
|
||||
- Keep the API layer split as `types.ts` -> `service.ts` -> `queries.ts` -> `mutations.ts`
|
||||
- Prefer `useSuspenseQuery` with server prefetch
|
||||
- Use `mutationOptions` with `getQueryClient()`
|
||||
- Every CRUD mutation must define explicit post-success cache sync for affected queries
|
||||
- Use `apiClient` for local route-handler calls
|
||||
- Keep auth and role checks server-side for real protection
|
||||
- Avoid direct imports from `src/constants/mock-api*`
|
||||
- Avoid direct use of Clerk helpers or components in new examples
|
||||
|
||||
## What Good Guidance Looks Like
|
||||
|
||||
When this skill is working well, it should guide the agent toward:
|
||||
|
||||
- Auth.js session helpers instead of Clerk auth primitives
|
||||
- Drizzle schema and route handlers instead of mock stores
|
||||
- organization-aware server checks instead of client-only vendor hooks
|
||||
- incremental migration steps that match the repo's real starting point
|
||||
35
.agents/skills/kiranism-shadcn-dashboard/evals/evals.json
Normal file
35
.agents/skills/kiranism-shadcn-dashboard/evals/evals.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"skill_name": "kiranism-shadcn-dashboard",
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"prompt": "Migrate the products feature in this dashboard starter away from the mock API. Keep the existing React Query and table flow, but make the default architecture Drizzle-backed route handlers under src/app/api/products and a service.ts that calls those local endpoints.",
|
||||
"expected_output": "Guidance defaults to route handlers plus Drizzle, keeps the feature API layering, and treats mock data as legacy to be replaced.",
|
||||
"files": [
|
||||
"src/features/products/api/service.ts",
|
||||
"src/app/api/products/route.ts",
|
||||
"src/app/api/products/[id]/route.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"prompt": "Replace the Clerk-based protection on a dashboard page with Auth.js session checks and an app-owned organization membership model. I need the plan to cover middleware or protected routes, page-level guards, and how nav visibility should work without useOrganization/useUser.",
|
||||
"expected_output": "Guidance defaults to Auth.js, explains user-organization-membership-role vocabulary, and avoids Clerk-specific hooks or components.",
|
||||
"files": [
|
||||
"src/proxy.ts",
|
||||
"src/hooks/use-nav.ts",
|
||||
"src/components/layout/providers.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"prompt": "Add a new orders page to this repo. I want it built the way the project should look after migration: Drizzle schema first, route handlers for CRUD, feature api/types-service-queries-mutations, and dashboard UI with table filters.",
|
||||
"expected_output": "Guidance uses the new build order from schema to UI, includes route handlers and Auth.js-aware server checks, and does not recommend mock-api files.",
|
||||
"files": [
|
||||
"src/app/dashboard/orders/page.tsx",
|
||||
"src/features/orders/api/types.ts",
|
||||
"src/app/api/orders/route.ts"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
# Auth.js + Organization RBAC Guide
|
||||
|
||||
Use this guide when the task involves authentication, session-aware pages, or replacing Clerk-coupled access control.
|
||||
|
||||
## Target model
|
||||
|
||||
This repo should evolve toward an app-owned model:
|
||||
|
||||
- `user`: authenticated person
|
||||
- `organization`: active tenant or workspace
|
||||
- `membership`: join record between user and organization
|
||||
- `role`: coarse access level such as `owner`, `admin`, `member`
|
||||
- `permissions`: optional fine-grained capabilities
|
||||
|
||||
Auth.js authenticates the user. Your app owns the organization and RBAC data.
|
||||
|
||||
## Auth boundary
|
||||
|
||||
Prefer a single auth boundary that exports:
|
||||
|
||||
- Auth.js config
|
||||
- `auth()` session helper
|
||||
- provider definitions
|
||||
- callbacks that enrich the session with app-owned ids if needed
|
||||
|
||||
Typical locations:
|
||||
|
||||
- `auth.ts`
|
||||
- `src/auth.ts`
|
||||
|
||||
Keep usage consistent across:
|
||||
|
||||
- route handlers
|
||||
- protected server pages
|
||||
- shared server utilities
|
||||
|
||||
## Protection patterns
|
||||
|
||||
### Protected page
|
||||
|
||||
```tsx
|
||||
import { auth } from '@/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function BillingPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirect('/auth/sign-in');
|
||||
}
|
||||
|
||||
return <div>Protected page</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Protected route handler
|
||||
|
||||
```tsx
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
|
||||
export async function POST() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
```
|
||||
|
||||
## Membership checks
|
||||
|
||||
Prefer a reusable server helper for membership resolution so page and API protection behave the same way.
|
||||
|
||||
Good responsibilities for a helper:
|
||||
|
||||
- require an authenticated session
|
||||
- look up the active organization
|
||||
- load the user's membership
|
||||
- optionally enforce role or permissions
|
||||
|
||||
Example shape:
|
||||
|
||||
```tsx
|
||||
export async function requireOrganizationAccess(options?: {
|
||||
role?: 'owner' | 'admin' | 'member';
|
||||
permission?: string;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
const membership = await findActiveMembership(session.user.id);
|
||||
if (!membership) throw new Error('Organization membership required');
|
||||
|
||||
if (options?.role && membership.role !== options.role) {
|
||||
throw new Error('Forbidden');
|
||||
}
|
||||
|
||||
return { session, membership };
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation migration
|
||||
|
||||
The current repo uses Clerk hooks in `src/hooks/use-nav.ts`. Treat this as legacy.
|
||||
|
||||
Target behavior:
|
||||
|
||||
- nav visibility comes from app-owned session or membership data
|
||||
- client rendering may consume a serialized access snapshot
|
||||
- server pages and route handlers still enforce real access independently
|
||||
|
||||
If the task touches navigation, prefer one of these approaches:
|
||||
|
||||
1. compute filtered nav on the server and pass it into layout state
|
||||
2. expose a lightweight session or membership snapshot to the client
|
||||
3. keep client filtering, but source it from app-owned data rather than Clerk hooks
|
||||
|
||||
## Plan and feature gates
|
||||
|
||||
Do not map old Clerk Billing language directly into Auth.js.
|
||||
|
||||
Instead:
|
||||
|
||||
- store plan state on `organizations`, `subscriptions`, or entitlement tables
|
||||
- expose a server helper such as `requirePlan('pro')`
|
||||
- keep the page or API gate server-side
|
||||
|
||||
If there is no billing provider yet, document it as a placeholder and do not over-promise implementation detail.
|
||||
|
||||
## Migration advice for this repo
|
||||
|
||||
Clerk hot spots currently include:
|
||||
|
||||
- `src/proxy.ts`
|
||||
- `src/hooks/use-nav.ts`
|
||||
- `src/components/layout/providers.tsx`
|
||||
- `src/components/layout/user-nav.tsx`
|
||||
- `src/components/layout/app-sidebar.tsx`
|
||||
- `src/app/dashboard/workspaces/**`
|
||||
- `src/app/dashboard/billing/page.tsx`
|
||||
|
||||
Migrate in this order when possible:
|
||||
|
||||
1. auth config and protected-route shell
|
||||
2. session-aware layout or providers
|
||||
3. shared membership and RBAC utilities
|
||||
4. page-level and route-level conversions
|
||||
5. navigation and UI cleanup
|
||||
@@ -0,0 +1,419 @@
|
||||
# Charts & Analytics Guide
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview Architecture](#overview-architecture)
|
||||
2. [Parallel Routes Pattern](#parallel-routes-pattern)
|
||||
3. [Chart Components](#chart-components)
|
||||
4. [Stats Cards](#stats-cards)
|
||||
5. [Skeleton Loading](#skeleton-loading)
|
||||
6. [Adding a New Chart Section](#adding-a-new-chart-section)
|
||||
|
||||
---
|
||||
|
||||
## Overview Architecture
|
||||
|
||||
The analytics dashboard at `/dashboard/overview` uses **Next.js parallel routes** to load multiple chart sections independently. Each chart slot streams in as its data becomes ready — no waterfall, no blocking.
|
||||
|
||||
**File structure:**
|
||||
|
||||
```
|
||||
src/app/dashboard/overview/
|
||||
├── layout.tsx # Composes all slots into a grid
|
||||
├── @area_stats/
|
||||
│ ├── page.tsx # Async server component (fetches data)
|
||||
│ ├── loading.tsx # Skeleton shown while streaming
|
||||
│ └── error.tsx # Error boundary if fetch fails
|
||||
├── @bar_stats/
|
||||
│ ├── page.tsx
|
||||
│ ├── loading.tsx
|
||||
│ └── error.tsx
|
||||
├── @pie_stats/
|
||||
│ ├── page.tsx
|
||||
│ ├── loading.tsx
|
||||
│ └── error.tsx
|
||||
└── @sales/
|
||||
├── page.tsx
|
||||
├── loading.tsx
|
||||
└── error.tsx
|
||||
|
||||
src/features/overview/components/
|
||||
├── area-graph.tsx # Client chart component
|
||||
├── area-graph-skeleton.tsx # Matching skeleton
|
||||
├── bar-graph.tsx
|
||||
├── bar-graph-skeleton.tsx
|
||||
├── pie-graph.tsx
|
||||
├── pie-graph-skeleton.tsx
|
||||
├── recent-sales.tsx
|
||||
└── recent-sales-skeleton.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Routes Pattern
|
||||
|
||||
### Layout (`layout.tsx`)
|
||||
|
||||
The layout receives each parallel route as a prop and arranges them in a grid:
|
||||
|
||||
```tsx
|
||||
export default function OverviewLayout({
|
||||
sales,
|
||||
pie_stats,
|
||||
bar_stats,
|
||||
area_stats
|
||||
}: {
|
||||
sales: React.ReactNode;
|
||||
pie_stats: React.ReactNode;
|
||||
bar_stats: React.ReactNode;
|
||||
area_stats: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PageContainer pageTitle='Dashboard' pageDescription='Overview analytics.'>
|
||||
{/* Stats cards row */}
|
||||
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>Total Revenue</CardTitle>
|
||||
<Icons.billing className='h-4 w-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>$45,231.89</div>
|
||||
<p className='text-xs text-muted-foreground'>+20.1% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* ...more stat cards */}
|
||||
</div>
|
||||
|
||||
{/* Charts grid — each slot loads independently */}
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-7'>
|
||||
<div className='col-span-4'>{area_stats}</div>
|
||||
<div className='col-span-3'>{sales}</div>
|
||||
<div className='col-span-4'>{bar_stats}</div>
|
||||
<div className='col-span-3'>{pie_stats}</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Slot Page (`@area_stats/page.tsx`)
|
||||
|
||||
Each slot is an async server component that fetches data then renders the chart. For production work, prefer a real server fetch or route-handler call. A short `setTimeout` demo is acceptable only as a clearly labeled placeholder:
|
||||
|
||||
```tsx
|
||||
import { AreaGraph } from '@/features/overview/components/area-graph';
|
||||
|
||||
export default async function AreaStatsPage() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // Placeholder fetch delay
|
||||
return <AreaGraph />;
|
||||
}
|
||||
```
|
||||
|
||||
### Slot Loading (`@area_stats/loading.tsx`)
|
||||
|
||||
```tsx
|
||||
import { AreaGraphSkeleton } from '@/features/overview/components/area-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <AreaGraphSkeleton />;
|
||||
}
|
||||
```
|
||||
|
||||
### Slot Error (`@area_stats/error.tsx`)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Icons } from '@/components/icons';
|
||||
|
||||
export default function AreaStatsError({ error }: { error: Error }) {
|
||||
return (
|
||||
<Alert variant='destructive'>
|
||||
<Icons.alertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>Failed to load area stats: {error.message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Each slot can fail independently without affecting others.
|
||||
|
||||
---
|
||||
|
||||
## Chart Components
|
||||
|
||||
All chart components are `'use client'` and use **Recharts** wrapped in shadcn's `ChartContainer`.
|
||||
|
||||
### Chart Config
|
||||
|
||||
Every chart defines a config object mapping data keys to labels and theme colors:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent
|
||||
} from '@/components/ui/chart';
|
||||
|
||||
const chartConfig = {
|
||||
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
|
||||
mobile: { label: 'Mobile', color: 'var(--chart-2)' }
|
||||
} satisfies ChartConfig;
|
||||
```
|
||||
|
||||
Theme colors `--chart-1` through `--chart-5` are defined in each theme's CSS file and automatically adapt to light/dark mode.
|
||||
|
||||
### Area Chart Example
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts';
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent
|
||||
} from '@/components/ui/chart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Icons } from '@/components/icons';
|
||||
|
||||
const chartData = [
|
||||
{ month: 'January', desktop: 186, mobile: 80 },
|
||||
{ month: 'February', desktop: 305, mobile: 200 }
|
||||
// ...more months
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
|
||||
mobile: { label: 'Mobile', color: 'var(--chart-2)' }
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function AreaGraph() {
|
||||
return (
|
||||
<Card className='@container/card'>
|
||||
<CardHeader>
|
||||
<CardTitle>Area Chart - Stacked</CardTitle>
|
||||
<Badge variant='outline'>
|
||||
<Icons.trendingUp className='mr-1 h-3 w-3' /> +12.5%
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className='aspect-auto h-[250px] w-full'>
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='month'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => value.slice(0, 3)}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent indicator='dot' />} />
|
||||
<Area
|
||||
dataKey='mobile'
|
||||
type='natural'
|
||||
fill='var(--color-mobile)'
|
||||
stroke='var(--color-mobile)'
|
||||
stackId='a'
|
||||
/>
|
||||
<Area
|
||||
dataKey='desktop'
|
||||
type='natural'
|
||||
fill='var(--color-desktop)'
|
||||
stroke='var(--color-desktop)'
|
||||
stackId='a'
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Bar Chart Pattern
|
||||
|
||||
Same structure, using `BarChart` + `Bar`:
|
||||
|
||||
```tsx
|
||||
<ChartContainer config={chartConfig}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey='month' tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey='desktop' fill='var(--color-desktop)' radius={4} />
|
||||
<Bar dataKey='mobile' fill='var(--color-mobile)' radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
```
|
||||
|
||||
### Pie/Donut Chart Pattern
|
||||
|
||||
```tsx
|
||||
<ChartContainer config={chartConfig}>
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||
<Pie data={chartData} dataKey='visitors' nameKey='browser' innerRadius={30}>
|
||||
<LabelList dataKey='visitors' className='fill-background' />
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stats Cards
|
||||
|
||||
Stats cards are simple server-rendered `Card` components at the top of the layout — no parallel routes needed since they render instantly:
|
||||
|
||||
```tsx
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>Total Revenue</CardTitle>
|
||||
<Icons.billing className='h-4 w-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>$45,231.89</div>
|
||||
<p className='text-xs text-muted-foreground'>+20.1% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
For dynamic stats that need data fetching, wrap in their own Suspense boundary or parallel route slot.
|
||||
|
||||
---
|
||||
|
||||
## Skeleton Loading
|
||||
|
||||
Each chart has a matching skeleton component. Pattern:
|
||||
|
||||
```tsx
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function AreaGraphSkeleton() {
|
||||
return (
|
||||
<Card className='@container/card'>
|
||||
<CardHeader>
|
||||
<Skeleton className='h-5 w-[140px]' />
|
||||
<Skeleton className='h-4 w-[80px]' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className='h-[250px] w-full rounded-md' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Match the skeleton dimensions to the actual chart for smooth visual transitions.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Chart Section
|
||||
|
||||
To add a new chart (e.g., line chart for user growth):
|
||||
|
||||
### 1. Create the chart component
|
||||
|
||||
`src/features/overview/components/line-graph.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { Line, LineChart, CartesianGrid, XAxis } from 'recharts';
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent
|
||||
} from '@/components/ui/chart';
|
||||
|
||||
const chartConfig = {
|
||||
users: { label: 'Users', color: 'var(--chart-3)' }
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const chartData = [
|
||||
/* monthly user data */
|
||||
];
|
||||
|
||||
export function LineGraph() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Growth</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className='aspect-auto h-[250px] w-full'>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey='month' tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Line dataKey='users' type='monotone' stroke='var(--color-users)' strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create matching skeleton
|
||||
|
||||
`src/features/overview/components/line-graph-skeleton.tsx`
|
||||
|
||||
### 3. Create parallel route slot
|
||||
|
||||
```
|
||||
src/app/dashboard/overview/@line_stats/
|
||||
├── page.tsx → async, fetches data, returns <LineGraph />
|
||||
├── loading.tsx → returns <LineGraphSkeleton />
|
||||
├── error.tsx → error alert
|
||||
└── default.tsx → return null (fallback when route doesn't match)
|
||||
```
|
||||
|
||||
`default.tsx` is required for parallel routes — return `null` or a fallback:
|
||||
|
||||
```tsx
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add slot to layout
|
||||
|
||||
Update `src/app/dashboard/overview/layout.tsx`:
|
||||
|
||||
```tsx
|
||||
export default function OverviewLayout({
|
||||
sales,
|
||||
pie_stats,
|
||||
bar_stats,
|
||||
area_stats,
|
||||
line_stats // ← add new slot
|
||||
}: {
|
||||
/* ...types */
|
||||
}) {
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-7'>
|
||||
{/* existing charts */}
|
||||
<div className='col-span-4'>{line_stats}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Available Recharts Components
|
||||
|
||||
Common chart types to use with `ChartContainer`:
|
||||
|
||||
- `AreaChart` + `Area` — filled area charts (stacked or standalone)
|
||||
- `BarChart` + `Bar` — vertical/horizontal bars
|
||||
- `LineChart` + `Line` — line/trend charts
|
||||
- `PieChart` + `Pie` — pie/donut charts
|
||||
- `RadarChart` + `Radar` — radar/spider charts
|
||||
- `RadialBarChart` + `RadialBar` — radial progress bars
|
||||
|
||||
All support `ChartTooltip`, `ChartLegend`, and theme-aware colors via `var(--chart-N)`.
|
||||
@@ -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,81 @@
|
||||
# Migration From Clerk And Mock APIs
|
||||
|
||||
Use this guide when the task is explicitly about converting this starter toward Auth.js and Drizzle.
|
||||
|
||||
## What exists today
|
||||
|
||||
This repo still contains two major demo-era seams:
|
||||
|
||||
- auth and workspace flows built around Clerk
|
||||
- feature data flows built around `src/constants/mock-api*.ts`
|
||||
|
||||
The important thing is not to rewrite everything at once.
|
||||
|
||||
## Recommended migration order
|
||||
|
||||
### 1. Establish auth shell
|
||||
|
||||
Introduce Auth.js primitives first:
|
||||
|
||||
- auth config file
|
||||
- protected route or middleware strategy
|
||||
- session helper
|
||||
- sign-in and sign-out surface
|
||||
|
||||
Do this before touching feature CRUD so server handlers have a stable way to identify the user.
|
||||
|
||||
### 2. Establish organization and membership model
|
||||
|
||||
Add app-owned tables and types for:
|
||||
|
||||
- organizations
|
||||
- memberships
|
||||
- roles
|
||||
- optional permissions or entitlements
|
||||
|
||||
This replaces Clerk Organizations as the architectural source of truth.
|
||||
|
||||
### 3. Migrate one feature end-to-end
|
||||
|
||||
Pick a contained feature such as `products` or `users`.
|
||||
|
||||
Per feature:
|
||||
|
||||
- define Drizzle schema
|
||||
- replace route-handler mocks with Drizzle queries
|
||||
- point `service.ts` to local route handlers
|
||||
- leave UI query usage stable where possible
|
||||
|
||||
### 4. Convert RBAC-aware UI
|
||||
|
||||
After shared membership utilities exist:
|
||||
|
||||
- migrate `use-nav.ts`
|
||||
- migrate workspace or billing placeholders
|
||||
- replace Clerk-only UI pieces with app-owned equivalents
|
||||
|
||||
## How to speak about legacy files
|
||||
|
||||
Use accurate migration language:
|
||||
|
||||
- "legacy mock source"
|
||||
- "route-handler shell ready for Drizzle"
|
||||
- "Clerk-coupled hotspot"
|
||||
- "migration seam in `service.ts`"
|
||||
|
||||
Avoid language that suggests the old path is still the preferred architecture.
|
||||
|
||||
## Safe recommendations
|
||||
|
||||
Good:
|
||||
|
||||
- migrate feature-by-feature
|
||||
- centralize auth checks
|
||||
- centralize membership lookup
|
||||
- keep HTTP contracts stable while swapping the backing store
|
||||
|
||||
Risky:
|
||||
|
||||
- editing every page before the auth shell exists
|
||||
- mixing direct Drizzle calls into client-facing feature services
|
||||
- rebuilding UI and auth simultaneously without preserving route contracts
|
||||
@@ -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,147 @@
|
||||
# Route Handlers + Drizzle Guide
|
||||
|
||||
Use this guide when a feature should stop relying on mock data and start using the app's real server boundary.
|
||||
|
||||
## Default request flow
|
||||
|
||||
The preferred flow in this repo is:
|
||||
|
||||
`component -> service.ts -> /api route handler -> Drizzle -> database`
|
||||
|
||||
This keeps:
|
||||
|
||||
- React components UI-focused
|
||||
- route handlers responsible for auth, org resolution, and HTTP behavior
|
||||
- Drizzle access on the server only
|
||||
|
||||
Avoid these paths as defaults:
|
||||
|
||||
- `component -> mock-api`
|
||||
- `component -> Drizzle directly`
|
||||
- `client component -> external DB client`
|
||||
|
||||
## Build order
|
||||
|
||||
### 1. Schema first
|
||||
|
||||
Define tables and relations before writing React code.
|
||||
|
||||
Typical pieces:
|
||||
|
||||
- feature table such as `orders`
|
||||
- optional `organizationId` foreign key
|
||||
- audit columns such as `createdAt` and `updatedAt`
|
||||
|
||||
If the task includes org-aware access, make tenant ownership explicit in the schema.
|
||||
|
||||
### 2. API contract second
|
||||
|
||||
In `src/features/<feature>/api/types.ts`, define:
|
||||
|
||||
- entity shape used by the UI
|
||||
- list filters
|
||||
- list response
|
||||
- detail response
|
||||
- mutation payloads
|
||||
|
||||
Keep response types stable even if the schema is denormalized underneath.
|
||||
|
||||
### 3. Route handlers third
|
||||
|
||||
Implement:
|
||||
|
||||
- `src/app/api/<feature>/route.ts` for `GET` list and `POST` create
|
||||
- `src/app/api/<feature>/[id]/route.ts` for `GET`, `PATCH` or `PUT`, and `DELETE`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- authenticate the request
|
||||
- resolve active organization
|
||||
- check membership or role
|
||||
- parse request params and body
|
||||
- query or mutate via Drizzle
|
||||
- return consistent JSON
|
||||
|
||||
## Example list handler
|
||||
|
||||
```tsx
|
||||
import { and, count, desc, eq, ilike } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { memberships, orders } from '@/db/schema';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const page = Number(searchParams.get('page') ?? 1);
|
||||
const limit = Number(searchParams.get('limit') ?? 10);
|
||||
const search = searchParams.get('search') ?? '';
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const membership = await db.query.memberships.findFirst({
|
||||
where: eq(memberships.userId, session.user.id)
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return NextResponse.json({ message: 'Organization membership required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const whereClause = and(
|
||||
eq(orders.organizationId, membership.organizationId),
|
||||
search ? ilike(orders.customerName, `%${search}%`) : undefined
|
||||
);
|
||||
|
||||
const [items, totalRows] = await Promise.all([
|
||||
db.select().from(orders).where(whereClause).orderBy(desc(orders.createdAt)).limit(limit).offset(offset),
|
||||
db.select({ value: count() }).from(orders).where(whereClause)
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
items,
|
||||
totalItems: totalRows[0]?.value ?? 0
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Service layer pattern
|
||||
|
||||
`service.ts` should call the local route handlers through `apiClient`.
|
||||
|
||||
```tsx
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { Order, OrderFilters, OrdersResponse, OrderMutationPayload } from './types';
|
||||
|
||||
export async function getOrders(filters: OrderFilters): Promise<OrdersResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (filters.page) searchParams.set('page', String(filters.page));
|
||||
if (filters.limit) searchParams.set('limit', String(filters.limit));
|
||||
if (filters.search) searchParams.set('search', filters.search);
|
||||
if (filters.status) searchParams.set('status', filters.status);
|
||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||
|
||||
return apiClient<OrdersResponse>(`/orders?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export async function createOrder(values: OrderMutationPayload): Promise<Order> {
|
||||
return apiClient<Order>('/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values)
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Migration advice for this repo
|
||||
|
||||
If the task is migrating existing code:
|
||||
|
||||
- start with `products` or `users` because both already have route-handler shells
|
||||
- swap `service.ts` away from `fakeProducts` or `fakeUsers`
|
||||
- replace route-handler mock implementations with Drizzle queries
|
||||
- keep `queries.ts` and UI mostly stable where possible
|
||||
|
||||
That minimizes UI churn and isolates the migration to the intended seam.
|
||||
@@ -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