T07a: frontend core SPA — shell + auth + routing + i18n + UI library + a11y
- React 18 + Vite + TypeScript + Tailwind CSS setup - AppShell with Sidebar (plugin menu) + TopBar (tenant switcher, search, notifications, user menu) - Auth pages: Login, PasswordResetRequest, PasswordResetConfirm - Protected routes with auth guard - API client (axios with interceptors: session cookie, 401 redirect, 422 validation) - TanStack Query hooks for auth, users, companies, contacts, notifications - Zustand stores: authStore, uiStore - i18n setup (de/en locales) with react-i18next - UI component library: Button, Input, Select, Modal, Toast, Table, Card, Badge, Avatar, Pagination, EmptyState, Skeleton, ConfirmDialog - Accessibility: ARIA labels, 44px touch targets, keyboard nav, reduced-motion, sr-only - Design tokens from prototype as CSS custom properties - 111 tests passing across 20 test files - tsc --noEmit: 0 errors - npm run build: success (471KB JS, 24KB CSS)
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import axios, { AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
export interface ApiError {
|
||||
status: number;
|
||||
message: string;
|
||||
detail?: string;
|
||||
validationErrors?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
let onValidationError: ((errors: Record<string, string[]>) => void) | null = null;
|
||||
|
||||
export function setUnauthorizedHandler(handler: () => void) {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
export function setValidationErrorHandler(handler: (errors: Record<string, string[]>) => void) {
|
||||
onValidationError = handler;
|
||||
}
|
||||
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
const status = error.response?.status || 0;
|
||||
const data = error.response?.data as any;
|
||||
|
||||
if (status === 401) {
|
||||
if (onUnauthorized) {
|
||||
onUnauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 422 && data?.detail) {
|
||||
const validationErrors: Record<string, string[]> = {};
|
||||
if (Array.isArray(data.detail)) {
|
||||
for (const item of data.detail) {
|
||||
if (item.loc && item.loc.length > 1) {
|
||||
const field = item.loc[item.loc.length - 1];
|
||||
if (!validationErrors[field]) {
|
||||
validationErrors[field] = [];
|
||||
}
|
||||
validationErrors[field].push(item.msg || 'Invalid value');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onValidationError) {
|
||||
onValidationError(validationErrors);
|
||||
}
|
||||
}
|
||||
|
||||
const apiError: ApiError = {
|
||||
status,
|
||||
message: data?.detail?.message || data?.message || error.message || 'An error occurred',
|
||||
detail: typeof data?.detail === 'string' ? data.detail : undefined,
|
||||
validationErrors: status === 422 ? extractValidationErrors(data) : undefined,
|
||||
};
|
||||
|
||||
return Promise.reject(apiError);
|
||||
}
|
||||
);
|
||||
|
||||
function extractValidationErrors(data: any): Record<string, string[]> | undefined {
|
||||
if (!data?.detail || !Array.isArray(data.detail)) return undefined;
|
||||
const errors: Record<string, string[]> = {};
|
||||
for (const item of data.detail) {
|
||||
if (item.loc && item.loc.length > 1) {
|
||||
const field = item.loc[item.loc.length - 1];
|
||||
if (!errors[field]) {
|
||||
errors[field] = [];
|
||||
}
|
||||
errors[field].push(item.msg || 'Invalid value');
|
||||
}
|
||||
}
|
||||
return Object.keys(errors).length > 0 ? errors : undefined;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await apiClient.get<T>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function apiPost<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await apiClient.post<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function apiPut<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await apiClient.put<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await apiClient.patch<T>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await apiClient.delete<T>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiPost, apiGet, apiPatch, apiDelete } from './client';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
export interface LoginPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequestPayload {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetConfirmPayload {
|
||||
token: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const { setUser, setError } = useAuthStore();
|
||||
return useMutation({
|
||||
mutationFn: (payload: LoginPayload) =>
|
||||
apiPost('/auth/login', payload),
|
||||
onSuccess: (data: any) => {
|
||||
setUser(data.user || data);
|
||||
setError(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setError(error.message || 'Login failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const { logout } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => apiPost('/auth/logout'),
|
||||
onSettled: () => {
|
||||
logout();
|
||||
queryClient.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCurrentUser() {
|
||||
const { setUser, setAuthenticated } = useAuthStore();
|
||||
return useQuery({
|
||||
queryKey: ['currentUser'],
|
||||
queryFn: async () => {
|
||||
const data = await apiGet<any>('/auth/me');
|
||||
setUser(data.user || data);
|
||||
setAuthenticated(true);
|
||||
return data;
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePasswordResetRequest() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: PasswordResetRequestPayload) =>
|
||||
apiPost('/auth/password-reset/request', payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePasswordResetConfirm() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: PasswordResetConfirmPayload) =>
|
||||
apiPost('/auth/password-reset/confirm', payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSwitchTenant() {
|
||||
const { setTenant } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (tenantId: string) =>
|
||||
apiPost('/auth/switch-tenant', { tenant_id: tenantId }),
|
||||
onSuccess: (data: any) => {
|
||||
setTenant(data.tenant || data);
|
||||
queryClient.invalidateQueries();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsers(page = 1, pageSize = 25) {
|
||||
return useQuery({
|
||||
queryKey: ['users', page, pageSize],
|
||||
queryFn: () =>
|
||||
apiGet<PaginatedResponse<any>>(`/users?page=${page}&page_size=${pageSize}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompanies(page = 1, pageSize = 25, search?: string) {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (search) params.set('search', search);
|
||||
return useQuery({
|
||||
queryKey: ['companies', page, pageSize, search],
|
||||
queryFn: () =>
|
||||
apiGet<PaginatedResponse<any>>(`/companies?${params.toString()}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useContacts(page = 1, pageSize = 25, search?: string) {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (search) params.set('search', search);
|
||||
return useQuery({
|
||||
queryKey: ['contacts', page, pageSize, search],
|
||||
queryFn: () =>
|
||||
apiGet<PaginatedResponse<any>>(`/contacts?${params.toString()}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
return useQuery({
|
||||
queryKey: ['notifications'],
|
||||
queryFn: () => apiGet<PaginatedResponse<any>>('/notifications'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadNotificationCount() {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
queryFn: () => apiGet<number>('/notifications/unread-count'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkNotificationRead() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiPatch(`/notifications/${id}/read`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteNotification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/notifications/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlugins() {
|
||||
return useQuery({
|
||||
queryKey: ['plugins'],
|
||||
queryFn: () => apiGet<any[]>('/plugins'),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user