forked from cardosofelipe/fast-next-template
Add comprehensive frontend requirements document
- Created `frontend-requirements.md` outlining detailed specifications for a production-ready Next.js + FastAPI template. - Documented technology stack, architecture, state management, authentication flows, API integration, UI components, and developer guidelines. - Provided a complete directory layout, coding conventions, and error handling practices. - Aimed to establish a solid foundation for modern, scalable, and maintainable web application development.
This commit is contained in:
913
frontend/docs/API_INTEGRATION.md
Normal file
913
frontend/docs/API_INTEGRATION.md
Normal file
@@ -0,0 +1,913 @@
|
|||||||
|
# API Integration Guide
|
||||||
|
|
||||||
|
**Project**: Next.js + FastAPI Template
|
||||||
|
**Version**: 1.0
|
||||||
|
**Last Updated**: 2025-10-31
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Quick Start](#1-quick-start)
|
||||||
|
2. [Generating the API Client](#2-generating-the-api-client)
|
||||||
|
3. [Making API Calls](#3-making-api-calls)
|
||||||
|
4. [Authentication Integration](#4-authentication-integration)
|
||||||
|
5. [Error Handling](#5-error-handling)
|
||||||
|
6. [React Query Integration](#6-react-query-integration)
|
||||||
|
7. [Testing API Integration](#7-testing-api-integration)
|
||||||
|
8. [Common Patterns](#8-common-patterns)
|
||||||
|
9. [Troubleshooting](#9-troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Quick Start
|
||||||
|
|
||||||
|
### 1.1 Prerequisites
|
||||||
|
|
||||||
|
1. Backend running at `http://localhost:8000`
|
||||||
|
2. Frontend environment variables configured:
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Generate API Client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run generate:api
|
||||||
|
```
|
||||||
|
|
||||||
|
This fetches the OpenAPI spec from the backend and generates TypeScript types and API client functions.
|
||||||
|
|
||||||
|
### 1.3 Make Your First API Call
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
|
||||||
|
function UserList() {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get('/users');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading...</div>;
|
||||||
|
return <div>{/* Render users */}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Generating the API Client
|
||||||
|
|
||||||
|
### 2.1 Generation Script
|
||||||
|
|
||||||
|
The generation script fetches the OpenAPI specification from the backend and creates TypeScript types and API client code.
|
||||||
|
|
||||||
|
**Script Location**: `frontend/scripts/generate-api-client.sh`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
API_URL="${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000}"
|
||||||
|
OUTPUT_DIR="./src/lib/api/generated"
|
||||||
|
|
||||||
|
echo "Fetching OpenAPI spec from $API_URL/api/v1/openapi.json..."
|
||||||
|
|
||||||
|
npx @hey-api/openapi-ts \
|
||||||
|
--input "$API_URL/api/v1/openapi.json" \
|
||||||
|
--output "$OUTPUT_DIR" \
|
||||||
|
--client axios \
|
||||||
|
--types
|
||||||
|
|
||||||
|
echo "✅ API client generated successfully in $OUTPUT_DIR"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Generated Files
|
||||||
|
|
||||||
|
After running the script, you'll have:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/lib/api/generated/
|
||||||
|
├── index.ts # Main exports
|
||||||
|
├── models/ # TypeScript interfaces for all models
|
||||||
|
│ ├── User.ts
|
||||||
|
│ ├── Organization.ts
|
||||||
|
│ ├── UserSession.ts
|
||||||
|
│ └── ...
|
||||||
|
└── services/ # API service functions
|
||||||
|
├── AuthService.ts
|
||||||
|
├── UsersService.ts
|
||||||
|
├── OrganizationsService.ts
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 When to Regenerate
|
||||||
|
|
||||||
|
Regenerate the API client when:
|
||||||
|
- Backend API changes (new endpoints, updated models)
|
||||||
|
- After pulling backend changes from git
|
||||||
|
- When types don't match backend responses
|
||||||
|
- As part of CI/CD pipeline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Making API Calls
|
||||||
|
|
||||||
|
### 3.1 Using Generated Services
|
||||||
|
|
||||||
|
**Example: Fetching users**
|
||||||
|
```typescript
|
||||||
|
import { UsersService } from '@/lib/api/generated';
|
||||||
|
|
||||||
|
async function getUsers() {
|
||||||
|
const users = await UsersService.getUsers({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
search: 'john'
|
||||||
|
});
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example: Creating a user**
|
||||||
|
```typescript
|
||||||
|
import { AdminService } from '@/lib/api/generated';
|
||||||
|
|
||||||
|
async function createUser(data: CreateUserDto) {
|
||||||
|
const newUser = await AdminService.createUser({
|
||||||
|
requestBody: data
|
||||||
|
});
|
||||||
|
return newUser;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Using Axios Client Directly
|
||||||
|
|
||||||
|
For more control, use the configured Axios instance:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
|
||||||
|
// GET request
|
||||||
|
const response = await apiClient.get<User[]>('/users', {
|
||||||
|
params: { page: 1, search: 'john' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST request
|
||||||
|
const response = await apiClient.post<User>('/admin/users', {
|
||||||
|
email: 'user@example.com',
|
||||||
|
first_name: 'John',
|
||||||
|
password: 'secure123'
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH request
|
||||||
|
const response = await apiClient.patch<User>(`/users/${userId}`, {
|
||||||
|
first_name: 'Jane'
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE request
|
||||||
|
await apiClient.delete(`/users/${userId}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Request Configuration
|
||||||
|
|
||||||
|
**Timeouts:**
|
||||||
|
```typescript
|
||||||
|
const response = await apiClient.get('/users', {
|
||||||
|
timeout: 5000 // 5 seconds
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom Headers:**
|
||||||
|
```typescript
|
||||||
|
const response = await apiClient.post('/users', data, {
|
||||||
|
headers: {
|
||||||
|
'X-Custom-Header': 'value'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Cancellation:**
|
||||||
|
```typescript
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const response = await apiClient.get('/users', {
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel the request
|
||||||
|
controller.abort();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Authentication Integration
|
||||||
|
|
||||||
|
### 4.1 Automatic Token Injection
|
||||||
|
|
||||||
|
The Axios client automatically adds the Authorization header to all requests:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/api/client.ts
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = useAuthStore.getState().accessToken;
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
You don't need to manually add auth headers - they're added automatically!
|
||||||
|
|
||||||
|
### 4.2 Token Refresh Flow
|
||||||
|
|
||||||
|
The response interceptor handles token refresh automatically:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
// If 401 and haven't retried yet
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Refresh tokens
|
||||||
|
const refreshToken = getRefreshToken();
|
||||||
|
const { access_token, refresh_token } = await AuthService.refreshToken({
|
||||||
|
requestBody: { refresh_token: refreshToken }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update stored tokens
|
||||||
|
useAuthStore.getState().setTokens(access_token, refresh_token);
|
||||||
|
|
||||||
|
// Retry original request with new token
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||||
|
return apiClient.request(originalRequest);
|
||||||
|
|
||||||
|
} catch (refreshError) {
|
||||||
|
// Refresh failed - logout user
|
||||||
|
useAuthStore.getState().clearAuth();
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Login Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { AuthService } from '@/lib/api/generated';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
|
||||||
|
async function login(email: string, password: string) {
|
||||||
|
try {
|
||||||
|
const response = await AuthService.login({
|
||||||
|
requestBody: { email, password }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store tokens and user
|
||||||
|
useAuthStore.getState().setTokens(
|
||||||
|
response.access_token,
|
||||||
|
response.refresh_token
|
||||||
|
);
|
||||||
|
useAuthStore.getState().setUser(response.user);
|
||||||
|
|
||||||
|
return response.user;
|
||||||
|
} catch (error) {
|
||||||
|
throw parseAPIError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Error Handling
|
||||||
|
|
||||||
|
### 5.1 Backend Error Format
|
||||||
|
|
||||||
|
The backend returns structured errors:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
code: "AUTH_001",
|
||||||
|
message: "Invalid credentials",
|
||||||
|
field: "email"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Parsing Errors
|
||||||
|
|
||||||
|
**Error Parser** (`src/lib/api/errors.ts`):
|
||||||
|
```typescript
|
||||||
|
import type { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
export interface APIError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
field?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIErrorResponse {
|
||||||
|
success: false;
|
||||||
|
errors: APIError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAPIError(error: AxiosError<APIErrorResponse>): APIError[] {
|
||||||
|
// Backend structured errors
|
||||||
|
if (error.response?.data?.errors) {
|
||||||
|
return error.response.data.errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network errors
|
||||||
|
if (!error.response) {
|
||||||
|
return [{
|
||||||
|
code: 'NETWORK_ERROR',
|
||||||
|
message: 'Network error. Please check your connection.',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP status errors
|
||||||
|
const status = error.response.status;
|
||||||
|
if (status === 403) {
|
||||||
|
return [{
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: "You don't have permission to perform this action.",
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
return [{
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'The requested resource was not found.',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 429) {
|
||||||
|
return [{
|
||||||
|
code: 'RATE_LIMIT',
|
||||||
|
message: 'Too many requests. Please slow down.',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 500) {
|
||||||
|
return [{
|
||||||
|
code: 'SERVER_ERROR',
|
||||||
|
message: 'A server error occurred. Please try again later.',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
return [{
|
||||||
|
code: 'UNKNOWN',
|
||||||
|
message: error.message || 'An unexpected error occurred.',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Error Code Mapping
|
||||||
|
|
||||||
|
**Error Messages** (`src/lib/api/errorMessages.ts`):
|
||||||
|
```typescript
|
||||||
|
export const ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
// Authentication errors (AUTH_xxx)
|
||||||
|
'AUTH_001': 'Invalid email or password',
|
||||||
|
'AUTH_002': 'Account is inactive',
|
||||||
|
'AUTH_003': 'Invalid or expired token',
|
||||||
|
|
||||||
|
// User errors (USER_xxx)
|
||||||
|
'USER_001': 'User not found',
|
||||||
|
'USER_002': 'This email is already registered',
|
||||||
|
'USER_003': 'Invalid user data',
|
||||||
|
|
||||||
|
// Validation errors (VAL_xxx)
|
||||||
|
'VAL_001': 'Invalid input. Please check your data.',
|
||||||
|
'VAL_002': 'Email format is invalid',
|
||||||
|
'VAL_003': 'Password does not meet requirements',
|
||||||
|
|
||||||
|
// Organization errors (ORG_xxx)
|
||||||
|
'ORG_001': 'Organization name already exists',
|
||||||
|
'ORG_002': 'Organization not found',
|
||||||
|
|
||||||
|
// Permission errors (PERM_xxx)
|
||||||
|
'PERM_001': 'Insufficient permissions',
|
||||||
|
'PERM_002': 'Admin access required',
|
||||||
|
|
||||||
|
// Rate limiting (RATE_xxx)
|
||||||
|
'RATE_001': 'Too many requests. Please try again later.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getErrorMessage(code: string): string {
|
||||||
|
return ERROR_MESSAGES[code] || 'An error occurred';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Displaying Errors
|
||||||
|
|
||||||
|
**In React Query:**
|
||||||
|
```typescript
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { parseAPIError, getErrorMessage } from '@/lib/api/errors';
|
||||||
|
|
||||||
|
export function useUpdateUser() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: updateUserFn,
|
||||||
|
onError: (error: AxiosError) => {
|
||||||
|
const errors = parseAPIError(error);
|
||||||
|
const message = getErrorMessage(errors[0]?.code) || errors[0]?.message;
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**In Forms:**
|
||||||
|
```typescript
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
await updateUser(data);
|
||||||
|
} catch (error) {
|
||||||
|
const errors = parseAPIError(error);
|
||||||
|
|
||||||
|
// Set field-specific errors
|
||||||
|
errors.forEach((err) => {
|
||||||
|
if (err.field) {
|
||||||
|
form.setError(err.field as any, {
|
||||||
|
message: getErrorMessage(err.code) || err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set general error
|
||||||
|
if (errors.some(err => !err.field)) {
|
||||||
|
form.setError('root', {
|
||||||
|
message: errors.find(err => !err.field)?.message || 'An error occurred',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. React Query Integration
|
||||||
|
|
||||||
|
### 6.1 Creating Query Hooks
|
||||||
|
|
||||||
|
**Pattern: One hook per operation**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/api/hooks/useUsers.ts
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { UsersService, AdminService } from '@/lib/api/generated';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// Query: List users
|
||||||
|
export function useUsers(filters?: UserFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['users', filters],
|
||||||
|
queryFn: () => UsersService.getUsers(filters),
|
||||||
|
staleTime: 60000, // 1 minute
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query: Single user
|
||||||
|
export function useUser(userId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['users', userId],
|
||||||
|
queryFn: () => UsersService.getUser({ userId: userId! }),
|
||||||
|
enabled: !!userId, // Only run if userId exists
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutation: Create user
|
||||||
|
export function useCreateUser() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateUserDto) =>
|
||||||
|
AdminService.createUser({ requestBody: data }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
toast.success('User created successfully');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const errors = parseAPIError(error);
|
||||||
|
toast.error(errors[0]?.message || 'Failed to create user');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutation: Update user
|
||||||
|
export function useUpdateUser() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateUserDto }) =>
|
||||||
|
UsersService.updateUser({ userId: id, requestBody: data }),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
toast.success('User updated successfully');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const errors = parseAPIError(error);
|
||||||
|
toast.error(errors[0]?.message || 'Failed to update user');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutation: Delete user
|
||||||
|
export function useDeleteUser() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (userId: string) =>
|
||||||
|
AdminService.deleteUser({ userId }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
toast.success('User deleted successfully');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const errors = parseAPIError(error);
|
||||||
|
toast.error(errors[0]?.message || 'Failed to delete user');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Using Query Hooks in Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useUsers, useDeleteUser } from '@/lib/api/hooks/useUsers';
|
||||||
|
|
||||||
|
export function UserList() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const { data: users, isLoading, error } = useUsers({ search });
|
||||||
|
const deleteUser = useDeleteUser();
|
||||||
|
|
||||||
|
const handleDelete = (userId: string) => {
|
||||||
|
if (confirm('Are you sure?')) {
|
||||||
|
deleteUser.mutate(userId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingSpinner />;
|
||||||
|
if (error) return <ErrorMessage error={error} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search users..."
|
||||||
|
/>
|
||||||
|
<ul>
|
||||||
|
{users?.map(user => (
|
||||||
|
<li key={user.id}>
|
||||||
|
{user.name}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(user.id)}
|
||||||
|
disabled={deleteUser.isPending}
|
||||||
|
>
|
||||||
|
{deleteUser.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Optimistic Updates
|
||||||
|
|
||||||
|
For instant UI feedback:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useToggleUserActive() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
|
||||||
|
AdminService.updateUser({
|
||||||
|
userId,
|
||||||
|
requestBody: { is_active: isActive }
|
||||||
|
}),
|
||||||
|
onMutate: async ({ userId, isActive }) => {
|
||||||
|
// Cancel outgoing refetches
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['users', userId] });
|
||||||
|
|
||||||
|
// Snapshot previous value
|
||||||
|
const previousUser = queryClient.getQueryData(['users', userId]);
|
||||||
|
|
||||||
|
// Optimistically update
|
||||||
|
queryClient.setQueryData(['users', userId], (old: User) => ({
|
||||||
|
...old,
|
||||||
|
is_active: isActive,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { previousUser };
|
||||||
|
},
|
||||||
|
onError: (err, variables, context) => {
|
||||||
|
// Rollback on error
|
||||||
|
if (context?.previousUser) {
|
||||||
|
queryClient.setQueryData(['users', variables.userId], context.previousUser);
|
||||||
|
}
|
||||||
|
toast.error('Failed to update user');
|
||||||
|
},
|
||||||
|
onSettled: (_, __, { userId }) => {
|
||||||
|
// Refetch to ensure consistency
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users', userId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Testing API Integration
|
||||||
|
|
||||||
|
### 7.1 Mocking API Calls
|
||||||
|
|
||||||
|
**Using MSW (Mock Service Worker):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// tests/mocks/handlers.ts
|
||||||
|
import { rest } from 'msw';
|
||||||
|
|
||||||
|
export const handlers = [
|
||||||
|
rest.get('/api/v1/users', (req, res, ctx) => {
|
||||||
|
return res(
|
||||||
|
ctx.json({
|
||||||
|
data: [
|
||||||
|
{ id: '1', name: 'John Doe', email: 'john@example.com' },
|
||||||
|
{ id: '2', name: 'Jane Smith', email: 'jane@example.com' },
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
total_pages: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
rest.post('/api/v1/admin/users', async (req, res, ctx) => {
|
||||||
|
const body = await req.json();
|
||||||
|
return res(
|
||||||
|
ctx.json({
|
||||||
|
id: '3',
|
||||||
|
...body,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
rest.delete('/api/v1/admin/users/:userId', (req, res, ctx) => {
|
||||||
|
return res(
|
||||||
|
ctx.json({ success: true, message: 'User deleted' })
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Testing Query Hooks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { useUsers } from './useUsers';
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
return ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('fetches users successfully', async () => {
|
||||||
|
const { result } = renderHook(() => useUsers(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(result.current.data).toHaveLength(2);
|
||||||
|
expect(result.current.data[0].name).toBe('John Doe');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Common Patterns
|
||||||
|
|
||||||
|
### 8.1 Pagination
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useUsersPaginated(page: number = 1, pageSize: number = 20) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['users', { page, pageSize }],
|
||||||
|
queryFn: () => UsersService.getUsers({ page, pageSize }),
|
||||||
|
keepPreviousData: true, // Keep old data while fetching new page
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component usage
|
||||||
|
function UserList() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const { data, isLoading, isFetching } = useUsersPaginated(page);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingSpinner />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul>
|
||||||
|
{data?.data.map(user => <li key={user.id}>{user.name}</li>)}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => p - 1)}
|
||||||
|
disabled={page === 1 || isFetching}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
disabled={!data?.pagination.has_next || isFetching}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Infinite Scroll
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useUsersInfinite() {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: ['users', 'infinite'],
|
||||||
|
queryFn: ({ pageParam = 1 }) =>
|
||||||
|
UsersService.getUsers({ page: pageParam, pageSize: 20 }),
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.pagination.has_next ? lastPage.pagination.page + 1 : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component usage
|
||||||
|
function InfiniteUserList() {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useUsersInfinite();
|
||||||
|
|
||||||
|
const allUsers = data?.pages.flatMap(page => page.data) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ul>
|
||||||
|
{allUsers.map(user => <li key={user.id}>{user.name}</li>)}
|
||||||
|
</ul>
|
||||||
|
{hasNextPage && (
|
||||||
|
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
|
||||||
|
{isFetchingNextPage ? 'Loading more...' : 'Load More'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Dependent Queries
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function UserDetail({ userId }: { userId: string }) {
|
||||||
|
// First query
|
||||||
|
const { data: user } = useUser(userId);
|
||||||
|
|
||||||
|
// Second query depends on first
|
||||||
|
const { data: sessions } = useQuery({
|
||||||
|
queryKey: ['sessions', userId],
|
||||||
|
queryFn: () => SessionService.getUserSessions({ userId }),
|
||||||
|
enabled: !!user, // Only fetch when user is loaded
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{user?.name}</h1>
|
||||||
|
<h2>Active Sessions</h2>
|
||||||
|
<ul>
|
||||||
|
{sessions?.map(session => <li key={session.id}>{session.device_name}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Troubleshooting
|
||||||
|
|
||||||
|
### 9.1 CORS Errors
|
||||||
|
|
||||||
|
**Symptom**: `Access-Control-Allow-Origin` error in console
|
||||||
|
|
||||||
|
**Solution**: Ensure backend CORS is configured for frontend URL:
|
||||||
|
```python
|
||||||
|
# backend/app/main.py
|
||||||
|
BACKEND_CORS_ORIGINS = ["http://localhost:3000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 401 Unauthorized
|
||||||
|
|
||||||
|
**Symptom**: All API calls return 401
|
||||||
|
|
||||||
|
**Possible Causes**:
|
||||||
|
1. No token in store: Check `useAuthStore.getState().accessToken`
|
||||||
|
2. Token expired: Check token expiration
|
||||||
|
3. Token invalid: Try logging in again
|
||||||
|
4. Interceptor not working: Check interceptor configuration
|
||||||
|
|
||||||
|
**Debug**:
|
||||||
|
```typescript
|
||||||
|
// Log token in interceptor
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = useAuthStore.getState().accessToken;
|
||||||
|
console.log('Token:', token ? 'Present' : 'Missing');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Type Mismatches
|
||||||
|
|
||||||
|
**Symptom**: TypeScript errors about response types
|
||||||
|
|
||||||
|
**Solution**: Regenerate API client to sync with backend
|
||||||
|
```bash
|
||||||
|
npm run generate:api
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 Stale Data
|
||||||
|
|
||||||
|
**Symptom**: UI shows old data after mutation
|
||||||
|
|
||||||
|
**Solution**: Invalidate queries after mutations
|
||||||
|
```typescript
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.5 Network Timeout
|
||||||
|
|
||||||
|
**Symptom**: Requests timeout
|
||||||
|
|
||||||
|
**Solution**: Increase timeout or check backend performance
|
||||||
|
```typescript
|
||||||
|
const apiClient = axios.create({
|
||||||
|
timeout: 60000, // 60 seconds
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
This guide covers the essential patterns for integrating with the FastAPI backend. For more advanced use cases, refer to:
|
||||||
|
- [TanStack Query Documentation](https://tanstack.com/query/latest)
|
||||||
|
- [Axios Documentation](https://axios-http.com/)
|
||||||
|
- Backend API documentation at `/docs` endpoint
|
||||||
1226
frontend/docs/ARCHITECTURE.md
Normal file
1226
frontend/docs/ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
1290
frontend/docs/CODING_STANDARDS.md
Normal file
1290
frontend/docs/CODING_STANDARDS.md
Normal file
File diff suppressed because it is too large
Load Diff
802
frontend/docs/COMPONENT_GUIDE.md
Normal file
802
frontend/docs/COMPONENT_GUIDE.md
Normal file
@@ -0,0 +1,802 @@
|
|||||||
|
# Component Guide
|
||||||
|
|
||||||
|
**Project**: Next.js + FastAPI Template
|
||||||
|
**Version**: 1.0
|
||||||
|
**Last Updated**: 2025-10-31
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [shadcn/ui Components](#1-shadcn-ui-components)
|
||||||
|
2. [Custom Components](#2-custom-components)
|
||||||
|
3. [Component Composition](#3-component-composition)
|
||||||
|
4. [Customization](#4-customization)
|
||||||
|
5. [Accessibility](#5-accessibility)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. shadcn/ui Components
|
||||||
|
|
||||||
|
### 1.1 Overview
|
||||||
|
|
||||||
|
This project uses [shadcn/ui](https://ui.shadcn.com), a collection of accessible, customizable components built on Radix UI primitives. Components are copied into the project (not installed as npm dependencies), giving you full control.
|
||||||
|
|
||||||
|
**Installation Method:**
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add button card input table dialog
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Core Components
|
||||||
|
|
||||||
|
#### Button
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
// Variants
|
||||||
|
<Button variant="default">Default</Button>
|
||||||
|
<Button variant="destructive">Delete</Button>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
<Button variant="ghost">Ghost</Button>
|
||||||
|
<Button variant="link">Link</Button>
|
||||||
|
|
||||||
|
// Sizes
|
||||||
|
<Button size="default">Default</Button>
|
||||||
|
<Button size="sm">Small</Button>
|
||||||
|
<Button size="lg">Large</Button>
|
||||||
|
<Button size="icon"><IconName /></Button>
|
||||||
|
|
||||||
|
// States
|
||||||
|
<Button disabled>Disabled</Button>
|
||||||
|
<Button loading>Loading...</Button>
|
||||||
|
|
||||||
|
// As Link
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/users">View Users</Link>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Card
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card';
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Users</CardTitle>
|
||||||
|
<CardDescription>Manage system users</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p>Card content goes here</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button>Action</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dialog / Modal
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>Open Dialog</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete User</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this user? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm}>Delete</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Form
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
const form = useForm();
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="email@example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Your email address</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Table
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell } from '@/components/ui/table';
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>{user.name}</TableCell>
|
||||||
|
<TableCell>{user.email}</TableCell>
|
||||||
|
<TableCell>{user.role}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Toast / Notifications
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// Success
|
||||||
|
toast.success('User created successfully');
|
||||||
|
|
||||||
|
// Error
|
||||||
|
toast.error('Failed to delete user');
|
||||||
|
|
||||||
|
// Info
|
||||||
|
toast.info('Processing your request...');
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
toast.loading('Saving changes...');
|
||||||
|
|
||||||
|
// Custom
|
||||||
|
toast('Event has been created', {
|
||||||
|
description: 'Monday, January 3rd at 6:00pm',
|
||||||
|
action: {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: () => console.log('Undo'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tabs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
|
||||||
|
<Tabs defaultValue="profile">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="profile">Profile</TabsTrigger>
|
||||||
|
<TabsTrigger value="password">Password</TabsTrigger>
|
||||||
|
<TabsTrigger value="sessions">Sessions</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="profile">
|
||||||
|
<ProfileSettings />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="password">
|
||||||
|
<PasswordSettings />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="sessions">
|
||||||
|
<SessionManagement />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Custom Components
|
||||||
|
|
||||||
|
### 2.1 Layout Components
|
||||||
|
|
||||||
|
#### Header
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
|
||||||
|
// Usage (in layout.tsx)
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
// Features:
|
||||||
|
// - Logo/brand
|
||||||
|
// - Navigation links
|
||||||
|
// - User menu (avatar, name, dropdown)
|
||||||
|
// - Theme toggle
|
||||||
|
// - Mobile menu button
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PageContainer
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { PageContainer } from '@/components/layout/PageContainer';
|
||||||
|
|
||||||
|
<PageContainer>
|
||||||
|
<h1>Page Title</h1>
|
||||||
|
<p>Page content...</p>
|
||||||
|
</PageContainer>
|
||||||
|
|
||||||
|
// Provides:
|
||||||
|
// - Consistent padding
|
||||||
|
// - Max-width container
|
||||||
|
// - Responsive layout
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PageHeader
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Users"
|
||||||
|
description="Manage system users"
|
||||||
|
action={<Button>Create User</Button>}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Data Display Components
|
||||||
|
|
||||||
|
#### DataTable
|
||||||
|
|
||||||
|
Generic, reusable data table with sorting, filtering, and pagination.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { DataTable } from '@/components/common/DataTable';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
// Define columns
|
||||||
|
const columns: ColumnDef<User>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'email',
|
||||||
|
header: 'Email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button onClick={() => handleEdit(row.original)}>Edit</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Use DataTable
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={users}
|
||||||
|
searchKey="name"
|
||||||
|
searchPlaceholder="Search users..."
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### LoadingSpinner
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
|
|
||||||
|
// Sizes
|
||||||
|
<LoadingSpinner size="sm" />
|
||||||
|
<LoadingSpinner size="md" />
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
|
||||||
|
// With text
|
||||||
|
<LoadingSpinner size="md" className="my-8">
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">Loading users...</p>
|
||||||
|
</LoadingSpinner>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### EmptyState
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { EmptyState } from '@/components/common/EmptyState';
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
icon={<Users className="h-12 w-12" />}
|
||||||
|
title="No users found"
|
||||||
|
description="Get started by creating a new user"
|
||||||
|
action={
|
||||||
|
<Button onClick={() => router.push('/admin/users/new')}>
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Admin Components
|
||||||
|
|
||||||
|
#### UserTable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { UserTable } from '@/components/admin/UserTable';
|
||||||
|
|
||||||
|
<UserTable
|
||||||
|
filters={{ search: 'john', is_active: true }}
|
||||||
|
onUserSelect={(user) => console.log(user)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Features:
|
||||||
|
// - Search
|
||||||
|
// - Filters (role, status)
|
||||||
|
// - Sorting
|
||||||
|
// - Pagination
|
||||||
|
// - Bulk selection
|
||||||
|
// - Bulk actions (activate, deactivate, delete)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### UserForm
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { UserForm } from '@/components/admin/UserForm';
|
||||||
|
|
||||||
|
// Create mode
|
||||||
|
<UserForm
|
||||||
|
mode="create"
|
||||||
|
onSuccess={() => router.push('/admin/users')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Edit mode
|
||||||
|
<UserForm
|
||||||
|
mode="edit"
|
||||||
|
user={user}
|
||||||
|
onSuccess={() => toast.success('User updated')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Features:
|
||||||
|
// - Validation with Zod
|
||||||
|
// - Field errors
|
||||||
|
// - Loading states
|
||||||
|
// - Cancel/Submit actions
|
||||||
|
```
|
||||||
|
|
||||||
|
#### OrganizationTable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { OrganizationTable } from '@/components/admin/OrganizationTable';
|
||||||
|
|
||||||
|
<OrganizationTable />
|
||||||
|
|
||||||
|
// Features:
|
||||||
|
// - Search
|
||||||
|
// - Member count display
|
||||||
|
// - Actions (edit, delete, view members)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### BulkActionBar
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BulkActionBar } from '@/components/admin/BulkActionBar';
|
||||||
|
|
||||||
|
<BulkActionBar
|
||||||
|
selectedIds={selectedUserIds}
|
||||||
|
onAction={(action) => handleBulkAction(action, selectedUserIds)}
|
||||||
|
onClearSelection={() => setSelectedUserIds([])}
|
||||||
|
actions={[
|
||||||
|
{ value: 'activate', label: 'Activate' },
|
||||||
|
{ value: 'deactivate', label: 'Deactivate' },
|
||||||
|
{ value: 'delete', label: 'Delete', variant: 'destructive' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Displays:
|
||||||
|
// - Selection count
|
||||||
|
// - Action dropdown
|
||||||
|
// - Confirmation dialogs
|
||||||
|
// - Progress indicators
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Settings Components
|
||||||
|
|
||||||
|
#### ProfileSettings
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ProfileSettings } from '@/components/settings/ProfileSettings';
|
||||||
|
|
||||||
|
<ProfileSettings
|
||||||
|
user={currentUser}
|
||||||
|
onUpdate={(updatedUser) => console.log('Updated:', updatedUser)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Fields:
|
||||||
|
// - First name, last name
|
||||||
|
// - Email (readonly)
|
||||||
|
// - Phone number
|
||||||
|
// - Avatar upload (optional)
|
||||||
|
// - Preferences
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PasswordSettings
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { PasswordSettings } from '@/components/settings/PasswordSettings';
|
||||||
|
|
||||||
|
<PasswordSettings />
|
||||||
|
|
||||||
|
// Fields:
|
||||||
|
// - Current password
|
||||||
|
// - New password
|
||||||
|
// - Confirm password
|
||||||
|
// - Option to logout all other devices
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SessionManagement
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SessionManagement } from '@/components/settings/SessionManagement';
|
||||||
|
|
||||||
|
<SessionManagement />
|
||||||
|
|
||||||
|
// Features:
|
||||||
|
// - List all active sessions
|
||||||
|
// - Current session badge
|
||||||
|
// - Device icons
|
||||||
|
// - Location display
|
||||||
|
// - Last used timestamp
|
||||||
|
// - Revoke session button
|
||||||
|
// - Logout all other devices button
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SessionCard
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SessionCard } from '@/components/settings/SessionCard';
|
||||||
|
|
||||||
|
<SessionCard
|
||||||
|
session={session}
|
||||||
|
isCurrent={session.is_current}
|
||||||
|
onRevoke={() => revokeSession(session.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Displays:
|
||||||
|
// - Device icon (desktop/mobile/tablet)
|
||||||
|
// - Device name
|
||||||
|
// - Location (city, country)
|
||||||
|
// - IP address
|
||||||
|
// - Last used (relative time)
|
||||||
|
// - "This device" badge if current
|
||||||
|
// - Revoke button (disabled for current)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Chart Components
|
||||||
|
|
||||||
|
#### BarChartCard
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BarChartCard } from '@/components/charts/BarChartCard';
|
||||||
|
|
||||||
|
<BarChartCard
|
||||||
|
title="User Registrations"
|
||||||
|
description="Monthly user registrations"
|
||||||
|
data={[
|
||||||
|
{ month: 'Jan', count: 45 },
|
||||||
|
{ month: 'Feb', count: 52 },
|
||||||
|
{ month: 'Mar', count: 61 },
|
||||||
|
]}
|
||||||
|
dataKey="count"
|
||||||
|
xAxisKey="month"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### LineChartCard
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { LineChartCard } from '@/components/charts/LineChartCard';
|
||||||
|
|
||||||
|
<LineChartCard
|
||||||
|
title="Active Users"
|
||||||
|
description="Daily active users over time"
|
||||||
|
data={dailyActiveUsers}
|
||||||
|
dataKey="count"
|
||||||
|
xAxisKey="date"
|
||||||
|
color="hsl(var(--primary))"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PieChartCard
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { PieChartCard } from '@/components/charts/PieChartCard';
|
||||||
|
|
||||||
|
<PieChartCard
|
||||||
|
title="Users by Role"
|
||||||
|
description="Distribution of user roles"
|
||||||
|
data={[
|
||||||
|
{ name: 'Admin', value: 10 },
|
||||||
|
{ name: 'User', value: 245 },
|
||||||
|
{ name: 'Guest', value: 56 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Component Composition
|
||||||
|
|
||||||
|
### 3.1 Form + Dialog Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create User</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new user to the system
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<UserForm
|
||||||
|
mode="create"
|
||||||
|
onSuccess={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
queryClient.invalidateQueries(['users']);
|
||||||
|
}}
|
||||||
|
onCancel={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Card + Table Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Users</CardTitle>
|
||||||
|
<CardDescription>Manage system users</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => router.push('/admin/users/new')}>
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<UserTable />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Tabs + Settings Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Account Settings</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="profile">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="profile">Profile</TabsTrigger>
|
||||||
|
<TabsTrigger value="password">Password</TabsTrigger>
|
||||||
|
<TabsTrigger value="sessions">Sessions</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="profile">
|
||||||
|
<ProfileSettings />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="password">
|
||||||
|
<PasswordSettings />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="sessions">
|
||||||
|
<SessionManagement />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Bulk Actions Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function UserList() {
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const { data: users } = useUsers();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{selectedIds.length > 0 && (
|
||||||
|
<BulkActionBar
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onAction={handleBulkAction}
|
||||||
|
onClearSelection={() => setSelectedIds([])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DataTable
|
||||||
|
data={users}
|
||||||
|
columns={columns}
|
||||||
|
enableRowSelection
|
||||||
|
onRowSelectionChange={setSelectedIds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Customization
|
||||||
|
|
||||||
|
### 4.1 Theming
|
||||||
|
|
||||||
|
Colors are defined in `tailwind.config.ts` using CSS variables:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// tailwind.config.ts
|
||||||
|
export default {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Customize colors in `globals.css`:**
|
||||||
|
```css
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Component Variants
|
||||||
|
|
||||||
|
Add new variants to existing components:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/ui/button.tsx
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'base-classes',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: '...',
|
||||||
|
destructive: '...',
|
||||||
|
outline: '...',
|
||||||
|
// Add custom variant
|
||||||
|
success: 'bg-green-600 text-white hover:bg-green-700',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
<Button variant="success">Activate</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Extending Components
|
||||||
|
|
||||||
|
Create wrapper components:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/common/ConfirmDialog.tsx
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={onCancel}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Accessibility
|
||||||
|
|
||||||
|
### 5.1 Keyboard Navigation
|
||||||
|
|
||||||
|
All shadcn/ui components support keyboard navigation:
|
||||||
|
- `Tab`: Move focus
|
||||||
|
- `Enter`/`Space`: Activate
|
||||||
|
- `Escape`: Close dialogs/dropdowns
|
||||||
|
- Arrow keys: Navigate lists/menus
|
||||||
|
|
||||||
|
### 5.2 Screen Reader Support
|
||||||
|
|
||||||
|
Components include proper ARIA labels:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<button aria-label="Close dialog">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div role="status" aria-live="polite">
|
||||||
|
Loading users...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
aria-invalid={!!errors.email}
|
||||||
|
aria-describedby="email-error"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Focus Management
|
||||||
|
|
||||||
|
Dialog components automatically manage focus:
|
||||||
|
- Focus trap inside dialog
|
||||||
|
- Return focus on close
|
||||||
|
- Focus first focusable element
|
||||||
|
|
||||||
|
### 5.4 Color Contrast
|
||||||
|
|
||||||
|
All theme colors meet WCAG 2.1 Level AA standards:
|
||||||
|
- Normal text: 4.5:1 contrast ratio
|
||||||
|
- Large text: 3:1 contrast ratio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
This guide covers the essential components in the project. For more details:
|
||||||
|
- **shadcn/ui docs**: https://ui.shadcn.com
|
||||||
|
- **Radix UI docs**: https://www.radix-ui.com
|
||||||
|
- **TanStack Table docs**: https://tanstack.com/table
|
||||||
|
- **Recharts docs**: https://recharts.org
|
||||||
|
|
||||||
|
For implementation examples, see `FEATURE_EXAMPLES.md`.
|
||||||
1059
frontend/docs/FEATURE_EXAMPLES.md
Normal file
1059
frontend/docs/FEATURE_EXAMPLES.md
Normal file
File diff suppressed because it is too large
Load Diff
1933
frontend/frontend-requirements.md
Normal file
1933
frontend/frontend-requirements.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user