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