diff --git a/__tests__/contexts/AuthContext.test.tsx b/__tests__/contexts/AuthContext.test.tsx
new file mode 100644
index 0000000..ddd123c
--- /dev/null
+++ b/__tests__/contexts/AuthContext.test.tsx
@@ -0,0 +1,437 @@
+/**
+ * Tests for the AuthContext
+ * @jest-environment jsdom
+ */
+
+import React from 'react';
+import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
+import { AuthProvider, useAuth } from '@/contexts/AuthContext';
+import { authService } from '@/services/auth.service';
+import toast from 'react-hot-toast';
+
+// Mock authService
+jest.mock('@/services/auth.service', () => ({
+ authService: {
+ login: jest.fn(),
+ register: jest.fn(),
+ logout: jest.fn(),
+ getCurrentUser: jest.fn(),
+ googleLogin: jest.fn(),
+ facebookLogin: jest.fn(),
+ },
+}));
+
+// Mock react-hot-toast
+jest.mock('react-hot-toast', () => ({
+ success: jest.fn(),
+ error: jest.fn(),
+}));
+
+// Mock utils/helpers
+jest.mock('@/utils/helpers', () => ({
+ setToken: jest.fn(),
+ removeToken: jest.fn(),
+ getToken: jest.fn(),
+ storage: {
+ get: jest.fn(),
+ set: jest.fn(),
+ remove: jest.fn(),
+ },
+}));
+
+import { setToken, removeToken, getToken, storage } from '@/utils/helpers';
+
+// Mock next/navigation
+const mockPush = jest.fn();
+const mockReplace = jest.fn();
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: mockPush,
+ replace: mockReplace,
+ prefetch: jest.fn(),
+ }),
+}));
+
+// Test component that uses the hook
+const TestComponent: React.FC = () => {
+ const { user, isLoading, isAuthenticated, login, logout, register } = useAuth();
+
+ if (isLoading) return
Loading...
;
+
+ const handleLogin = async () => {
+ try {
+ await login({ email: 'test@test.com', password: 'password' });
+ } catch {
+ // Error is handled in AuthContext
+ }
+ };
+
+ const handleRegister = async () => {
+ try {
+ await register({
+ email: 'new@test.com',
+ password: 'password',
+ firstName: 'John',
+ lastName: 'Doe',
+ });
+ } catch {
+ // Error is handled in AuthContext
+ }
+ };
+
+ return (
+
+
{isAuthenticated ? 'yes' : 'no'}
+
{user ? JSON.stringify(user) : 'null'}
+
+
+
+
+ );
+};
+
+describe('AuthContext', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (getToken as jest.Mock).mockReturnValue(null);
+ });
+
+ describe('Initial state', () => {
+ it('should show loading state when token exists', async () => {
+ // When token exists, it fetches user data which shows loading
+ (getToken as jest.Mock).mockReturnValue('valid-token');
+
+ // Delay the response to ensure loading state is visible
+ let resolveUser: (value: unknown) => void;
+ const userPromise = new Promise((resolve) => {
+ resolveUser = resolve;
+ });
+ (authService.getCurrentUser as jest.Mock).mockReturnValue(userPromise);
+
+ render(
+
+
+
+ );
+
+ // Should show loading while fetching user
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+
+ // Resolve the promise
+ await act(async () => {
+ resolveUser!({ id: '1', email: 'test@test.com', role: 'CLIENT' });
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
+ });
+ });
+
+ it('should have null user when no token exists', async () => {
+ render(
+
+
+
+ );
+
+ // When no token, loading completes immediately
+ await waitFor(() => {
+ expect(screen.getByTestId('user')).toHaveTextContent('null');
+ expect(screen.getByTestId('authenticated')).toHaveTextContent('no');
+ });
+ });
+
+ it('should load user when token exists', async () => {
+ const mockUser = {
+ id: '1',
+ email: 'test@test.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ role: 'CLIENT',
+ };
+
+ (getToken as jest.Mock).mockReturnValue('valid-token');
+ (authService.getCurrentUser as jest.Mock).mockResolvedValueOnce(mockUser);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('authenticated')).toHaveTextContent('yes');
+ });
+ });
+
+ it('should remove token if getCurrentUser fails', async () => {
+ (getToken as jest.Mock).mockReturnValue('invalid-token');
+ (authService.getCurrentUser as jest.Mock).mockRejectedValueOnce(
+ new Error('Invalid token')
+ );
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(removeToken).toHaveBeenCalled();
+ expect(storage.remove).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('login', () => {
+ it('should login successfully and redirect CLIENT', async () => {
+ const mockResponse = {
+ token: 'new-token',
+ user: {
+ id: '1',
+ email: 'test@test.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ role: 'CLIENT',
+ },
+ };
+
+ (authService.login as jest.Mock).mockResolvedValueOnce(mockResponse);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
+ });
+
+ const loginButton = screen.getByText('Login');
+ await act(async () => {
+ fireEvent.click(loginButton);
+ });
+
+ await waitFor(() => {
+ expect(setToken).toHaveBeenCalledWith('new-token');
+ expect(toast.success).toHaveBeenCalledWith('Connexion réussie !');
+ expect(mockReplace).toHaveBeenCalledWith('/client');
+ });
+ });
+
+ it('should redirect ADMIN to admin dashboard', async () => {
+ const mockResponse = {
+ token: 'admin-token',
+ user: {
+ id: '1',
+ email: 'admin@test.com',
+ firstName: 'Admin',
+ lastName: 'User',
+ role: 'ADMIN',
+ },
+ };
+
+ (authService.login as jest.Mock).mockResolvedValueOnce(mockResponse);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
+ });
+
+ const loginButton = screen.getByText('Login');
+ await act(async () => {
+ fireEvent.click(loginButton);
+ });
+
+ await waitFor(() => {
+ expect(mockReplace).toHaveBeenCalledWith('/admin/dashboard');
+ });
+ });
+
+ it('should redirect EMPLOYEE to employee dashboard', async () => {
+ const mockResponse = {
+ token: 'employee-token',
+ user: {
+ id: '1',
+ email: 'employee@test.com',
+ firstName: 'Employee',
+ lastName: 'User',
+ role: 'EMPLOYEE',
+ },
+ };
+
+ (authService.login as jest.Mock).mockResolvedValueOnce(mockResponse);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
+ });
+
+ const loginButton = screen.getByText('Login');
+ await act(async () => {
+ fireEvent.click(loginButton);
+ });
+
+ await waitFor(() => {
+ expect(mockReplace).toHaveBeenCalledWith('/employe/dashboard');
+ });
+ });
+
+ it('should show error toast on login failure', async () => {
+ const errorMessage = 'Invalid credentials';
+ (authService.login as jest.Mock).mockRejectedValueOnce(
+ new Error(errorMessage)
+ );
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
+ });
+
+ const loginButton = screen.getByText('Login');
+ await act(async () => {
+ fireEvent.click(loginButton);
+ });
+
+ await waitFor(() => {
+ expect(toast.error).toHaveBeenCalledWith(errorMessage);
+ });
+ });
+ });
+
+ describe('register', () => {
+ it('should register successfully', async () => {
+ const mockResponse = {
+ token: 'new-user-token',
+ user: {
+ id: '2',
+ email: 'new@test.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ role: 'CLIENT',
+ },
+ };
+
+ (authService.register as jest.Mock).mockResolvedValueOnce(mockResponse);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
+ });
+
+ const registerButton = screen.getByText('Register');
+ await act(async () => {
+ fireEvent.click(registerButton);
+ });
+
+ await waitFor(() => {
+ expect(setToken).toHaveBeenCalledWith('new-user-token');
+ expect(toast.success).toHaveBeenCalledWith('Inscription réussie !');
+ expect(mockPush).toHaveBeenCalledWith('/client');
+ });
+ });
+ });
+
+ describe('logout', () => {
+ it('should logout and redirect to login page', async () => {
+ const mockUser = {
+ id: '1',
+ email: 'test@test.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ role: 'CLIENT',
+ };
+
+ (getToken as jest.Mock).mockReturnValue('valid-token');
+ (authService.getCurrentUser as jest.Mock).mockResolvedValueOnce(mockUser);
+ (authService.logout as jest.Mock).mockResolvedValueOnce(undefined);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('authenticated')).toHaveTextContent('yes');
+ });
+
+ const logoutButton = screen.getByText('Logout');
+ await act(async () => {
+ fireEvent.click(logoutButton);
+ });
+
+ await waitFor(() => {
+ expect(removeToken).toHaveBeenCalled();
+ expect(storage.remove).toHaveBeenCalled();
+ expect(toast.success).toHaveBeenCalledWith('Déconnexion réussie');
+ expect(mockPush).toHaveBeenCalledWith('/login');
+ });
+ });
+
+ it('should still logout even if API call fails', async () => {
+ (getToken as jest.Mock).mockReturnValue('valid-token');
+ (authService.getCurrentUser as jest.Mock).mockResolvedValueOnce({
+ id: '1',
+ email: 'test@test.com',
+ role: 'CLIENT',
+ });
+ (authService.logout as jest.Mock).mockRejectedValueOnce(
+ new Error('Logout failed')
+ );
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('authenticated')).toHaveTextContent('yes');
+ });
+
+ const logoutButton = screen.getByText('Logout');
+ await act(async () => {
+ fireEvent.click(logoutButton);
+ });
+
+ await waitFor(() => {
+ expect(removeToken).toHaveBeenCalled();
+ expect(mockPush).toHaveBeenCalledWith('/login');
+ });
+ });
+ });
+
+ describe('useAuth hook error', () => {
+ it('should throw error when used outside AuthProvider', () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ expect(() => {
+ render();
+ }).toThrow('useAuth must be used within an AuthProvider');
+
+ consoleError.mockRestore();
+ });
+ });
+});
diff --git a/__tests__/hooks/useApi.test.ts b/__tests__/hooks/useApi.test.ts
new file mode 100644
index 0000000..17f350a
--- /dev/null
+++ b/__tests__/hooks/useApi.test.ts
@@ -0,0 +1,333 @@
+/**
+ * Tests for the useApi hook
+ * @jest-environment jsdom
+ */
+
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { useApi, useFetchData } from '@/hooks/useApi';
+import { api } from '@/services/api';
+
+// Mock the api module
+jest.mock('@/services/api', () => ({
+ api: {
+ get: jest.fn(),
+ post: jest.fn(),
+ put: jest.fn(),
+ patch: jest.fn(),
+ delete: jest.fn(),
+ },
+ ApiError: class ApiError extends Error {
+ constructor(public status: number, message: string, public data?: unknown) {
+ super(message);
+ this.name = 'ApiError';
+ }
+ },
+}));
+
+// Mock react-hot-toast - define mock inside factory to avoid hoisting issues
+jest.mock('react-hot-toast', () => {
+ const mockToast = {
+ error: jest.fn(),
+ success: jest.fn(),
+ };
+ return {
+ __esModule: true,
+ default: mockToast,
+ toast: mockToast,
+ error: mockToast.error,
+ success: mockToast.success,
+ };
+});
+
+// Import to access the mock for assertions
+import { toast } from 'react-hot-toast';
+
+describe('useApi Hook', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('initial state', () => {
+ it('should have correct initial state', () => {
+ const { result } = renderHook(() => useApi());
+
+ expect(result.current.data).toBeNull();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+ });
+
+ describe('execute', () => {
+ it('should execute GET request successfully', async () => {
+ const mockData = { id: 1, name: 'Test' };
+ (api.get as jest.Mock).mockResolvedValueOnce(mockData);
+
+ const { result } = renderHook(() => useApi());
+
+ let response: typeof mockData | null = null;
+ await act(async () => {
+ response = await result.current.execute('get', '/test');
+ });
+
+ expect(api.get).toHaveBeenCalledWith('/test');
+ expect(result.current.data).toEqual(mockData);
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeNull();
+ expect(response).toEqual(mockData);
+ });
+
+ it('should execute POST request with data', async () => {
+ const postData = { email: 'test@test.com' };
+ const mockResponse = { success: true };
+ (api.post as jest.Mock).mockResolvedValueOnce(mockResponse);
+
+ const { result } = renderHook(() => useApi());
+
+ await act(async () => {
+ await result.current.execute('post', '/auth/login', postData);
+ });
+
+ expect(api.post).toHaveBeenCalledWith('/auth/login', postData);
+ expect(result.current.data).toEqual(mockResponse);
+ });
+
+ it('should execute PUT request', async () => {
+ const putData = { name: 'Updated' };
+ (api.put as jest.Mock).mockResolvedValueOnce({ success: true });
+
+ const { result } = renderHook(() => useApi());
+
+ await act(async () => {
+ await result.current.execute('put', '/users/1', putData);
+ });
+
+ expect(api.put).toHaveBeenCalledWith('/users/1', putData);
+ });
+
+ it('should execute PATCH request', async () => {
+ const patchData = { status: 'active' };
+ (api.patch as jest.Mock).mockResolvedValueOnce({ success: true });
+
+ const { result } = renderHook(() => useApi());
+
+ await act(async () => {
+ await result.current.execute('patch', '/users/1', patchData);
+ });
+
+ expect(api.patch).toHaveBeenCalledWith('/users/1', patchData);
+ });
+
+ it('should execute DELETE request', async () => {
+ (api.delete as jest.Mock).mockResolvedValueOnce({ success: true });
+
+ const { result } = renderHook(() => useApi());
+
+ await act(async () => {
+ await result.current.execute('delete', '/users/1');
+ });
+
+ expect(api.delete).toHaveBeenCalledWith('/users/1');
+ });
+
+ it('should handle errors and show toast', async () => {
+ const errorMessage = 'Something went wrong';
+ (api.get as jest.Mock).mockRejectedValueOnce(new Error(errorMessage));
+
+ const { result } = renderHook(() => useApi());
+
+ await act(async () => {
+ await result.current.execute('get', '/test');
+ });
+
+ expect(result.current.data).toBeNull();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBe(errorMessage);
+ expect(toast.error).toHaveBeenCalledWith(errorMessage);
+ });
+
+ it('should return null on error', async () => {
+ (api.get as jest.Mock).mockRejectedValueOnce(new Error('Error'));
+
+ const { result } = renderHook(() => useApi());
+
+ let response: unknown = 'initial';
+ await act(async () => {
+ response = await result.current.execute('get', '/test');
+ });
+
+ expect(response).toBeNull();
+ });
+
+ it('should set loading state during request', async () => {
+ let resolvePromise: (value: unknown) => void;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+ (api.get as jest.Mock).mockReturnValueOnce(promise);
+
+ const { result } = renderHook(() => useApi());
+
+ act(() => {
+ result.current.execute('get', '/test');
+ });
+
+ // Should be loading immediately after calling execute
+ expect(result.current.loading).toBe(true);
+
+ // Resolve the promise
+ await act(async () => {
+ resolvePromise!({ data: 'test' });
+ await promise;
+ });
+
+ expect(result.current.loading).toBe(false);
+ });
+ });
+
+ describe('reset', () => {
+ it('should reset state to initial values', async () => {
+ const mockData = { id: 1 };
+ (api.get as jest.Mock).mockResolvedValueOnce(mockData);
+
+ const { result } = renderHook(() => useApi());
+
+ await act(async () => {
+ await result.current.execute('get', '/test');
+ });
+
+ expect(result.current.data).toEqual(mockData);
+
+ act(() => {
+ result.current.reset();
+ });
+
+ expect(result.current.data).toBeNull();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+ });
+});
+
+describe('useFetchData Hook', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should have correct initial state before refetch', () => {
+ const { result } = renderHook(() => useFetchData('/test'));
+
+ // Before calling refetch, data should be null and loading should be true
+ expect(result.current.data).toBeNull();
+ expect(result.current.loading).toBe(true);
+ expect(result.current.error).toBeNull();
+ expect(typeof result.current.refetch).toBe('function');
+ });
+
+ it('should extract data from response.data', async () => {
+ const mockData = { id: 1, name: 'Test' };
+ (api.get as jest.Mock).mockResolvedValue({ data: mockData });
+
+ const { result } = renderHook(() => useFetchData('/test'));
+
+ await act(async () => {
+ await result.current.refetch();
+ });
+
+ expect(result.current.data).toEqual(mockData);
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('should use response directly if no data field', async () => {
+ const mockData = { id: 1, name: 'Test' };
+ (api.get as jest.Mock).mockResolvedValue(mockData);
+
+ const { result } = renderHook(() => useFetchData('/test'));
+
+ await act(async () => {
+ await result.current.refetch();
+ });
+
+ expect(result.current.data).toEqual(mockData);
+ });
+
+ it('should call onSuccess callback', async () => {
+ const mockData = { id: 1 };
+ const onSuccess = jest.fn();
+ (api.get as jest.Mock).mockResolvedValue({ data: mockData });
+
+ const { result } = renderHook(() =>
+ useFetchData('/test', { onSuccess })
+ );
+
+ await act(async () => {
+ await result.current.refetch();
+ });
+
+ expect(onSuccess).toHaveBeenCalledWith(mockData);
+ });
+
+ it('should call onError callback on error', async () => {
+ const errorMessage = 'Fetch failed';
+ const onError = jest.fn();
+ (api.get as jest.Mock).mockRejectedValue(new Error(errorMessage));
+
+ const { result } = renderHook(() =>
+ useFetchData('/test', { onError, showErrorToast: false })
+ );
+
+ await act(async () => {
+ await result.current.refetch();
+ });
+
+ expect(onError).toHaveBeenCalledWith(errorMessage);
+ expect(result.current.error).toBe(errorMessage);
+ });
+
+ it('should show error toast by default', async () => {
+ (api.get as jest.Mock).mockRejectedValue(new Error('Error'));
+
+ const { result } = renderHook(() => useFetchData('/test'));
+
+ await act(async () => {
+ await result.current.refetch();
+ });
+
+ expect(toast.error).toHaveBeenCalled();
+ });
+
+ it('should not show error toast when showErrorToast is false', async () => {
+ (api.get as jest.Mock).mockRejectedValue(new Error('Error'));
+
+ const { result } = renderHook(() =>
+ useFetchData('/test', { showErrorToast: false })
+ );
+
+ await act(async () => {
+ await result.current.refetch();
+ });
+
+ expect(toast.error).not.toHaveBeenCalled();
+ });
+
+ it('should allow refetching data', async () => {
+ const mockData1 = { id: 1 };
+ const mockData2 = { id: 2 };
+ (api.get as jest.Mock)
+ .mockResolvedValueOnce({ data: mockData1 })
+ .mockResolvedValueOnce({ data: mockData2 });
+
+ const { result } = renderHook(() => useFetchData('/test'));
+
+ await act(async () => {
+ await result.current.refetch();
+ });
+
+ expect(result.current.data).toEqual(mockData1);
+
+ await act(async () => {
+ await result.current.refetch();
+ });
+
+ expect(result.current.data).toEqual(mockData2);
+ });
+});
diff --git a/__tests__/services/api.test.ts b/__tests__/services/api.test.ts
new file mode 100644
index 0000000..5c5f69d
--- /dev/null
+++ b/__tests__/services/api.test.ts
@@ -0,0 +1,276 @@
+/**
+ * Tests for the API service
+ * @jest-environment jsdom
+ */
+
+import { api, ApiError } from '@/services/api';
+
+// Mock fetch globally
+const mockFetch = jest.fn();
+global.fetch = mockFetch;
+
+// Mock helpers
+jest.mock('@/utils/helpers', () => ({
+ getToken: jest.fn(),
+}));
+
+import { getToken } from '@/utils/helpers';
+
+describe('API Service', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (getToken as jest.Mock).mockReturnValue(null);
+ });
+
+ describe('ApiError class', () => {
+ it('should create an error with status and message', () => {
+ const error = new ApiError(404, 'Not found');
+ expect(error.status).toBe(404);
+ expect(error.message).toBe('Not found');
+ expect(error.name).toBe('ApiError');
+ });
+
+ it('should create an error with data', () => {
+ const errorData = { field: 'email', reason: 'invalid' };
+ const error = new ApiError(400, 'Validation error', errorData);
+ expect(error.data).toEqual(errorData);
+ });
+ });
+
+ describe('api.get', () => {
+ it('should make a GET request without token', async () => {
+ const mockResponse = { data: 'test' };
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResponse,
+ });
+
+ const result = await api.get('/test');
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'http://localhost:4000/api/test',
+ expect.objectContaining({
+ method: 'GET',
+ headers: expect.objectContaining({
+ 'Content-Type': 'application/json',
+ }),
+ })
+ );
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should include Authorization header when token exists', async () => {
+ (getToken as jest.Mock).mockReturnValue('test-token');
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ await api.get('/test');
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: 'Bearer test-token',
+ }),
+ })
+ );
+ });
+
+ it('should handle full URLs', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ await api.get('http://external.api/endpoint');
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'http://external.api/endpoint',
+ expect.any(Object)
+ );
+ });
+
+ it('should throw ApiError on non-ok response', async () => {
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 401,
+ statusText: 'Unauthorized',
+ json: async () => ({ message: 'Invalid token' }),
+ });
+
+ await expect(api.get('/test')).rejects.toThrow(ApiError);
+
+ // Need a fresh mock for the second assertion
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 401,
+ statusText: 'Unauthorized',
+ json: async () => ({ message: 'Invalid token' }),
+ });
+
+ await expect(api.get('/test')).rejects.toMatchObject({
+ status: 401,
+ message: 'Invalid token',
+ });
+ });
+
+ it('should handle network errors', async () => {
+ mockFetch.mockRejectedValue(new Error('Network error'));
+
+ await expect(api.get('/test')).rejects.toThrow(ApiError);
+
+ // Fresh mock for the second assertion
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
+
+ await expect(api.get('/test')).rejects.toMatchObject({
+ status: 0,
+ message: 'Erreur de connexion au serveur',
+ });
+ });
+ });
+
+ describe('api.post', () => {
+ it('should make a POST request with data', async () => {
+ const postData = { email: 'test@test.com', password: 'password' };
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ await api.post('/auth/login', postData);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'http://localhost:4000/api/auth/login',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify(postData),
+ })
+ );
+ });
+
+ it('should make a POST request without data', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ await api.post('/auth/logout');
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ method: 'POST',
+ body: undefined,
+ })
+ );
+ });
+ });
+
+ describe('api.put', () => {
+ it('should make a PUT request with data', async () => {
+ const updateData = { name: 'New Name' };
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ await api.put('/users/1', updateData);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'http://localhost:4000/api/users/1',
+ expect.objectContaining({
+ method: 'PUT',
+ body: JSON.stringify(updateData),
+ })
+ );
+ });
+ });
+
+ describe('api.patch', () => {
+ it('should make a PATCH request with data', async () => {
+ const patchData = { status: 'active' };
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ await api.patch('/users/1', patchData);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'http://localhost:4000/api/users/1',
+ expect.objectContaining({
+ method: 'PATCH',
+ body: JSON.stringify(patchData),
+ })
+ );
+ });
+ });
+
+ describe('api.delete', () => {
+ it('should make a DELETE request', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ await api.delete('/users/1');
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'http://localhost:4000/api/users/1',
+ expect.objectContaining({
+ method: 'DELETE',
+ })
+ );
+ });
+ });
+
+ describe('Error handling', () => {
+ it('should extract error message from response', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 400,
+ statusText: 'Bad Request',
+ json: async () => ({ message: 'Email already exists' }),
+ });
+
+ try {
+ await api.post('/auth/register', {});
+ } catch (error) {
+ expect(error).toBeInstanceOf(ApiError);
+ expect((error as ApiError).message).toBe('Email already exists');
+ }
+ });
+
+ it('should use error field if message is not present', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 400,
+ statusText: 'Bad Request',
+ json: async () => ({ error: 'Validation failed' }),
+ });
+
+ try {
+ await api.post('/auth/register', {});
+ } catch (error) {
+ expect((error as ApiError).message).toBe('Validation failed');
+ }
+ });
+
+ it('should use statusText if JSON parsing fails', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ statusText: 'Internal Server Error',
+ json: async () => { throw new Error('Invalid JSON'); },
+ });
+
+ try {
+ await api.get('/test');
+ } catch (error) {
+ expect((error as ApiError).message).toBe('Internal Server Error');
+ }
+ });
+ });
+});
diff --git a/__tests__/services/auth.service.test.ts b/__tests__/services/auth.service.test.ts
new file mode 100644
index 0000000..77434d3
--- /dev/null
+++ b/__tests__/services/auth.service.test.ts
@@ -0,0 +1,276 @@
+/**
+ * Tests for the Auth Service
+ * @jest-environment jsdom
+ */
+
+import { authService } from '@/services/auth.service';
+import { api } from '@/services/api';
+import { API_ENDPOINTS } from '@/utils/constants';
+
+// Mock the api module
+jest.mock('@/services/api', () => ({
+ api: {
+ get: jest.fn(),
+ post: jest.fn(),
+ put: jest.fn(),
+ patch: jest.fn(),
+ delete: jest.fn(),
+ },
+ ApiError: class ApiError extends Error {
+ constructor(public status: number, message: string, public data?: unknown) {
+ super(message);
+ this.name = 'ApiError';
+ }
+ },
+}));
+
+describe('Auth Service', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('login', () => {
+ it('should login with valid credentials', async () => {
+ const mockResponse = {
+ success: true,
+ token: 'jwt-token-123',
+ user: {
+ id: '1',
+ email: 'test@test.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ role: 'CLIENT',
+ },
+ };
+
+ (api.post as jest.Mock).mockResolvedValueOnce(mockResponse);
+
+ const result = await authService.login({
+ email: 'test@test.com',
+ password: 'password123',
+ });
+
+ expect(api.post).toHaveBeenCalledWith(
+ API_ENDPOINTS.AUTH.LOGIN,
+ { email: 'test@test.com', password: 'password123' }
+ );
+ expect(result).toEqual({
+ token: 'jwt-token-123',
+ user: mockResponse.user,
+ });
+ });
+
+ it('should throw error on invalid credentials', async () => {
+ (api.post as jest.Mock).mockRejectedValueOnce(new Error('Invalid credentials'));
+
+ await expect(
+ authService.login({ email: 'test@test.com', password: 'wrong' })
+ ).rejects.toThrow('Invalid credentials');
+ });
+ });
+
+ describe('register', () => {
+ it('should register a new user', async () => {
+ const mockResponse = {
+ success: true,
+ token: 'jwt-token-new',
+ user: {
+ id: '2',
+ email: 'new@test.com',
+ firstName: 'Jane',
+ lastName: 'Doe',
+ role: 'CLIENT',
+ },
+ };
+
+ (api.post as jest.Mock).mockResolvedValueOnce(mockResponse);
+
+ const registerData = {
+ email: 'new@test.com',
+ password: 'password123',
+ firstName: 'Jane',
+ lastName: 'Doe',
+ };
+
+ const result = await authService.register(registerData);
+
+ expect(api.post).toHaveBeenCalledWith(
+ API_ENDPOINTS.AUTH.REGISTER,
+ registerData
+ );
+ expect(result.token).toBe('jwt-token-new');
+ expect(result.user.email).toBe('new@test.com');
+ });
+
+ it('should throw error if email already exists', async () => {
+ (api.post as jest.Mock).mockRejectedValueOnce(new Error('Email already exists'));
+
+ await expect(
+ authService.register({
+ email: 'existing@test.com',
+ password: 'password',
+ firstName: 'Test',
+ lastName: 'User',
+ })
+ ).rejects.toThrow('Email already exists');
+ });
+ });
+
+ describe('logout', () => {
+ it('should call logout endpoint', async () => {
+ (api.post as jest.Mock).mockResolvedValueOnce({ success: true });
+
+ await authService.logout();
+
+ expect(api.post).toHaveBeenCalledWith(API_ENDPOINTS.AUTH.LOGOUT);
+ });
+ });
+
+ describe('getCurrentUser', () => {
+ it('should return user data from response.data', async () => {
+ const mockUser = {
+ id: '1',
+ email: 'test@test.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ role: 'CLIENT',
+ };
+
+ (api.get as jest.Mock).mockResolvedValueOnce({
+ success: true,
+ data: mockUser,
+ });
+
+ const result = await authService.getCurrentUser();
+
+ expect(api.get).toHaveBeenCalledWith(API_ENDPOINTS.AUTH.ME);
+ expect(result).toEqual(mockUser);
+ });
+
+ it('should return user data from response.user', async () => {
+ const mockUser = {
+ id: '1',
+ email: 'test@test.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ role: 'CLIENT',
+ };
+
+ (api.get as jest.Mock).mockResolvedValueOnce({
+ success: true,
+ user: mockUser,
+ });
+
+ const result = await authService.getCurrentUser();
+
+ expect(result).toEqual(mockUser);
+ });
+
+ it('should return response directly if no data or user field', async () => {
+ const mockUser = {
+ id: '1',
+ email: 'test@test.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ role: 'CLIENT',
+ };
+
+ (api.get as jest.Mock).mockResolvedValueOnce(mockUser);
+
+ const result = await authService.getCurrentUser();
+
+ expect(result).toEqual(mockUser);
+ });
+ });
+
+ describe('googleLogin', () => {
+ it('should login with Google token', async () => {
+ const mockResponse = {
+ success: true,
+ token: 'jwt-google-token',
+ user: {
+ id: '3',
+ email: 'google@test.com',
+ firstName: 'Google',
+ lastName: 'User',
+ role: 'CLIENT',
+ },
+ };
+
+ (api.post as jest.Mock).mockResolvedValueOnce(mockResponse);
+
+ const result = await authService.googleLogin('google-oauth-token');
+
+ expect(api.post).toHaveBeenCalledWith(
+ API_ENDPOINTS.AUTH.GOOGLE,
+ { token: 'google-oauth-token' }
+ );
+ expect(result.token).toBe('jwt-google-token');
+ });
+ });
+
+ describe('facebookLogin', () => {
+ it('should login with Facebook token', async () => {
+ const mockResponse = {
+ success: true,
+ token: 'jwt-facebook-token',
+ user: {
+ id: '4',
+ email: 'facebook@test.com',
+ firstName: 'Facebook',
+ lastName: 'User',
+ role: 'CLIENT',
+ },
+ };
+
+ (api.post as jest.Mock).mockResolvedValueOnce(mockResponse);
+
+ const result = await authService.facebookLogin('facebook-oauth-token');
+
+ expect(api.post).toHaveBeenCalledWith(
+ API_ENDPOINTS.AUTH.FACEBOOK,
+ { token: 'facebook-oauth-token' }
+ );
+ expect(result.token).toBe('jwt-facebook-token');
+ });
+ });
+
+ describe('verifyEmail', () => {
+ it('should verify email with token', async () => {
+ (api.post as jest.Mock).mockResolvedValueOnce({ success: true });
+
+ await authService.verifyEmail('verification-token');
+
+ expect(api.post).toHaveBeenCalledWith(
+ API_ENDPOINTS.AUTH.VERIFY_EMAIL,
+ { token: 'verification-token' }
+ );
+ });
+ });
+
+ describe('forgotPassword', () => {
+ it('should send forgot password request', async () => {
+ (api.post as jest.Mock).mockResolvedValueOnce({ success: true });
+
+ await authService.forgotPassword('test@test.com');
+
+ expect(api.post).toHaveBeenCalledWith(
+ API_ENDPOINTS.AUTH.FORGOT_PASSWORD,
+ { email: 'test@test.com' }
+ );
+ });
+ });
+
+ describe('resetPassword', () => {
+ it('should reset password with token', async () => {
+ (api.post as jest.Mock).mockResolvedValueOnce({ success: true });
+
+ await authService.resetPassword('reset-token', 'newPassword123');
+
+ expect(api.post).toHaveBeenCalledWith(
+ API_ENDPOINTS.AUTH.RESET_PASSWORD,
+ { token: 'reset-token', password: 'newPassword123' }
+ );
+ });
+ });
+});
diff --git a/__tests__/utils/theme.test.ts b/__tests__/utils/theme.test.ts
new file mode 100644
index 0000000..c12e91a
--- /dev/null
+++ b/__tests__/utils/theme.test.ts
@@ -0,0 +1,174 @@
+/**
+ * Tests for the theme utility
+ */
+
+import {
+ STATUS_COLORS,
+ BADGE_COLORS,
+ BUTTON_STYLES,
+ INPUT_STYLES,
+ CARD_STYLES,
+ ALERT_STYLES,
+ ROLE_COLORS,
+ PRIZE_COLORS,
+ getStatusColor,
+ getButtonStyle,
+ getInputStyle,
+ getRoleColor,
+ getPrizeColor,
+} from '@/utils/theme';
+
+describe('Theme Utility', () => {
+ describe('Constants', () => {
+ it('should have all status colors defined', () => {
+ expect(STATUS_COLORS.PENDING).toBe('bg-yellow-100 text-yellow-800');
+ expect(STATUS_COLORS.CLAIMED).toBe('bg-green-100 text-green-800');
+ expect(STATUS_COLORS.REJECTED).toBe('bg-red-100 text-red-800');
+ expect(STATUS_COLORS.ACTIVE).toBe('bg-green-100 text-green-800');
+ expect(STATUS_COLORS.INACTIVE).toBe('bg-gray-100 text-gray-800');
+ expect(STATUS_COLORS.EXPIRED).toBe('bg-red-100 text-red-800');
+ });
+
+ it('should have all badge colors defined', () => {
+ expect(BADGE_COLORS.info).toBe('bg-blue-100 text-blue-800');
+ expect(BADGE_COLORS.success).toBe('bg-green-100 text-green-800');
+ expect(BADGE_COLORS.warning).toBe('bg-yellow-100 text-yellow-800');
+ expect(BADGE_COLORS.error).toBe('bg-red-100 text-red-800');
+ expect(BADGE_COLORS.purple).toBe('bg-purple-100 text-purple-800');
+ expect(BADGE_COLORS.pink).toBe('bg-pink-100 text-pink-800');
+ expect(BADGE_COLORS.amber).toBe('bg-amber-100 text-amber-800');
+ expect(BADGE_COLORS.gray).toBe('bg-gray-100 text-gray-800');
+ });
+
+ it('should have all button styles defined', () => {
+ expect(BUTTON_STYLES.primary).toContain('bg-blue-600');
+ expect(BUTTON_STYLES.secondary).toContain('bg-gray-600');
+ expect(BUTTON_STYLES.success).toContain('bg-green-600');
+ expect(BUTTON_STYLES.danger).toContain('bg-red-600');
+ expect(BUTTON_STYLES.warning).toContain('bg-yellow-600');
+ expect(BUTTON_STYLES.outline).toContain('border-blue-600');
+ expect(BUTTON_STYLES.ghost).toContain('text-gray-600');
+ });
+
+ it('should have input styles defined', () => {
+ expect(INPUT_STYLES.base).toContain('w-full');
+ expect(INPUT_STYLES.base).toContain('rounded-lg');
+ expect(INPUT_STYLES.error).toBe('border-red-500');
+ expect(INPUT_STYLES.normal).toBe('border-gray-300');
+ });
+
+ it('should have card styles defined', () => {
+ expect(CARD_STYLES.base).toContain('bg-white');
+ expect(CARD_STYLES.base).toContain('rounded-lg');
+ expect(CARD_STYLES.hover).toContain('hover:shadow-lg');
+ expect(CARD_STYLES.bordered).toContain('border-gray-200');
+ });
+
+ it('should have alert styles defined', () => {
+ expect(ALERT_STYLES.info).toContain('bg-blue-50');
+ expect(ALERT_STYLES.success).toContain('bg-green-50');
+ expect(ALERT_STYLES.warning).toContain('bg-yellow-50');
+ expect(ALERT_STYLES.error).toContain('bg-red-50');
+ });
+
+ it('should have role colors defined', () => {
+ expect(ROLE_COLORS.ADMIN).toBe('bg-red-100 text-red-800');
+ expect(ROLE_COLORS.EMPLOYEE).toBe('bg-blue-100 text-blue-800');
+ expect(ROLE_COLORS.CLIENT).toBe('bg-green-100 text-green-800');
+ });
+
+ it('should have prize colors defined', () => {
+ expect(PRIZE_COLORS.INFUSEUR).toBe('bg-blue-100 text-blue-800');
+ expect(PRIZE_COLORS.THE_SIGNATURE).toBe('bg-green-100 text-green-800');
+ expect(PRIZE_COLORS.COFFRET_DECOUVERTE).toBe('bg-purple-100 text-purple-800');
+ expect(PRIZE_COLORS.COFFRET_PRESTIGE).toBe('bg-amber-100 text-amber-800');
+ expect(PRIZE_COLORS.THE_GRATUIT).toBe('bg-pink-100 text-pink-800');
+ });
+ });
+
+ describe('getStatusColor', () => {
+ it('should return correct color for known statuses', () => {
+ expect(getStatusColor('PENDING')).toBe('bg-yellow-100 text-yellow-800');
+ expect(getStatusColor('CLAIMED')).toBe('bg-green-100 text-green-800');
+ expect(getStatusColor('REJECTED')).toBe('bg-red-100 text-red-800');
+ });
+
+ it('should handle lowercase statuses', () => {
+ expect(getStatusColor('pending')).toBe('bg-yellow-100 text-yellow-800');
+ expect(getStatusColor('claimed')).toBe('bg-green-100 text-green-800');
+ });
+
+ it('should return gray for unknown statuses', () => {
+ expect(getStatusColor('UNKNOWN')).toBe('bg-gray-100 text-gray-800');
+ expect(getStatusColor('random')).toBe('bg-gray-100 text-gray-800');
+ });
+ });
+
+ describe('getButtonStyle', () => {
+ it('should return primary style by default', () => {
+ expect(getButtonStyle()).toContain('bg-blue-600');
+ });
+
+ it('should return correct style for variant', () => {
+ expect(getButtonStyle('primary')).toContain('bg-blue-600');
+ expect(getButtonStyle('secondary')).toContain('bg-gray-600');
+ expect(getButtonStyle('success')).toContain('bg-green-600');
+ expect(getButtonStyle('danger')).toContain('bg-red-600');
+ expect(getButtonStyle('warning')).toContain('bg-yellow-600');
+ expect(getButtonStyle('outline')).toContain('border-blue-600');
+ expect(getButtonStyle('ghost')).toContain('text-gray-600');
+ });
+ });
+
+ describe('getInputStyle', () => {
+ it('should return normal input style when no error', () => {
+ const style = getInputStyle(false);
+ expect(style).toContain('w-full');
+ expect(style).toContain('border-gray-300');
+ expect(style).not.toContain('border-red-500');
+ });
+
+ it('should return error input style when has error', () => {
+ const style = getInputStyle(true);
+ expect(style).toContain('w-full');
+ expect(style).toContain('border-red-500');
+ expect(style).not.toContain('border-gray-300');
+ });
+ });
+
+ describe('getRoleColor', () => {
+ it('should return correct color for known roles', () => {
+ expect(getRoleColor('ADMIN')).toBe('bg-red-100 text-red-800');
+ expect(getRoleColor('EMPLOYEE')).toBe('bg-blue-100 text-blue-800');
+ expect(getRoleColor('CLIENT')).toBe('bg-green-100 text-green-800');
+ });
+
+ it('should handle lowercase roles', () => {
+ expect(getRoleColor('admin')).toBe('bg-red-100 text-red-800');
+ expect(getRoleColor('employee')).toBe('bg-blue-100 text-blue-800');
+ expect(getRoleColor('client')).toBe('bg-green-100 text-green-800');
+ });
+
+ it('should return gray for unknown roles', () => {
+ expect(getRoleColor('UNKNOWN')).toBe('bg-gray-100 text-gray-800');
+ });
+ });
+
+ describe('getPrizeColor', () => {
+ it('should return correct color for known prize types', () => {
+ expect(getPrizeColor('INFUSEUR')).toBe('bg-blue-100 text-blue-800');
+ expect(getPrizeColor('THE_SIGNATURE')).toBe('bg-green-100 text-green-800');
+ expect(getPrizeColor('COFFRET_DECOUVERTE')).toBe('bg-purple-100 text-purple-800');
+ expect(getPrizeColor('COFFRET_PRESTIGE')).toBe('bg-amber-100 text-amber-800');
+ expect(getPrizeColor('THE_GRATUIT')).toBe('bg-pink-100 text-pink-800');
+ });
+
+ it('should handle lowercase prize types', () => {
+ expect(getPrizeColor('infuseur')).toBe('bg-blue-100 text-blue-800');
+ });
+
+ it('should return gray for unknown prize types', () => {
+ expect(getPrizeColor('UNKNOWN')).toBe('bg-gray-100 text-gray-800');
+ });
+ });
+});
diff --git a/components/forms/BaseFormField.tsx b/components/forms/BaseFormField.tsx
new file mode 100644
index 0000000..43915ae
--- /dev/null
+++ b/components/forms/BaseFormField.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import React from 'react';
+
+interface BaseFormFieldProps {
+ label: string;
+ name: string;
+ required?: boolean;
+ error?: string;
+ touched?: boolean;
+ className?: string;
+ children: React.ReactNode;
+}
+
+/**
+ * Base form field wrapper component that provides consistent styling
+ * for labels, error messages, and layout across all form components.
+ */
+export default function BaseFormField({
+ label,
+ name,
+ required = false,
+ error,
+ touched,
+ className = '',
+ children,
+}: BaseFormFieldProps) {
+ const showError = touched && error;
+
+ return (
+
+
+ {children}
+ {showError && (
+
{error}
+ )}
+
+ );
+}
+
+// Common input class names for consistency
+export const inputBaseClasses = `
+ w-full px-4 py-2 border rounded-lg
+ focus:outline-none focus:ring-2 focus:ring-primary-500
+ disabled:bg-gray-100 disabled:cursor-not-allowed
+`;
+
+export const getInputClasses = (hasError: boolean): string => `
+ ${inputBaseClasses}
+ ${hasError ? 'border-red-500' : 'border-gray-300'}
+`;
diff --git a/components/forms/FormField.tsx b/components/forms/FormField.tsx
index 525e9c0..1a6d9d7 100644
--- a/components/forms/FormField.tsx
+++ b/components/forms/FormField.tsx
@@ -1,6 +1,7 @@
'use client';
import React, { ChangeEvent } from 'react';
+import BaseFormField, { getInputClasses } from './BaseFormField';
interface FormFieldProps {
label: string;
@@ -36,17 +37,15 @@ export default function FormField({
className = '',
autoComplete,
}: FormFieldProps) {
- const showError = touched && error;
-
return (
-
-
+
- {showError && (
- {error}
- )}
-
+
);
}
diff --git a/components/forms/FormSelect.tsx b/components/forms/FormSelect.tsx
index dc324a1..3d0173d 100644
--- a/components/forms/FormSelect.tsx
+++ b/components/forms/FormSelect.tsx
@@ -1,6 +1,7 @@
'use client';
import React, { ChangeEvent } from 'react';
+import BaseFormField, { getInputClasses } from './BaseFormField';
interface SelectOption {
value: string | number;
@@ -39,17 +40,15 @@ export default function FormSelect({
disabled = false,
className = '',
}: FormSelectProps) {
- const showError = touched && error;
-
return (
-
-
+
- {showError && (
- {error}
- )}
-
+
);
}
diff --git a/components/forms/FormTextarea.tsx b/components/forms/FormTextarea.tsx
index eec9469..78b5e03 100644
--- a/components/forms/FormTextarea.tsx
+++ b/components/forms/FormTextarea.tsx
@@ -1,6 +1,7 @@
'use client';
import React, { ChangeEvent } from 'react';
+import BaseFormField, { getInputClasses } from './BaseFormField';
interface FormTextareaProps {
label: string;
@@ -34,17 +35,15 @@ export default function FormTextarea({
rows = 4,
className = '',
}: FormTextareaProps) {
- const showError = touched && error;
-
return (
-
-
+
- {showError && (
- {error}
- )}
-
+
);
}
diff --git a/hooks/useApi.ts b/hooks/useApi.ts
index 09216ab..a484ff1 100644
--- a/hooks/useApi.ts
+++ b/hooks/useApi.ts
@@ -2,43 +2,10 @@
import { useState, useCallback } from 'react';
import { toast } from 'react-hot-toast';
+import { api, ApiError } from '@/services/api';
-const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
-
-/**
- * Get authentication token from localStorage
- */
-export const getAuthToken = (): string | null => {
- if (typeof window === 'undefined') return null;
- return localStorage.getItem('auth_token') || localStorage.getItem('token');
-};
-
-/**
- * Generic fetch wrapper with authentication
- */
-export const apiFetch = async (
- endpoint: string,
- options?: RequestInit
-): Promise => {
- const token = getAuthToken();
- const url = endpoint.startsWith('http') ? endpoint : `${API_BASE_URL}${endpoint}`;
-
- const response = await fetch(url, {
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...(token && { Authorization: `Bearer ${token}` }),
- ...options?.headers,
- },
- });
-
- if (!response.ok) {
- const error = await response.json().catch(() => ({ message: 'Erreur serveur' }));
- throw new Error(error.message || `Erreur ${response.status}`);
- }
-
- return response.json();
-};
+// Re-export for backward compatibility
+export { api, ApiError };
interface UseApiState {
data: T | null;
@@ -47,12 +14,17 @@ interface UseApiState {
}
interface UseApiReturn extends UseApiState {
- execute: (endpoint: string, options?: RequestInit) => Promise;
+ execute: (
+ method: 'get' | 'post' | 'put' | 'patch' | 'delete',
+ endpoint: string,
+ data?: unknown
+ ) => Promise;
reset: () => void;
}
/**
* Hook for API calls with loading and error states
+ * Uses centralized api service from services/api.ts
*/
export function useApi(): UseApiReturn {
const [state, setState] = useState>({
@@ -61,13 +33,34 @@ export function useApi(): UseApiReturn {
error: null,
});
- const execute = useCallback(async (endpoint: string, options?: RequestInit): Promise => {
+ const execute = useCallback(async (
+ method: 'get' | 'post' | 'put' | 'patch' | 'delete',
+ endpoint: string,
+ data?: unknown
+ ): Promise => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
- const data = await apiFetch(endpoint, options);
- setState({ data, loading: false, error: null });
- return data;
+ let result: R;
+ switch (method) {
+ case 'get':
+ result = await api.get(endpoint);
+ break;
+ case 'post':
+ result = await api.post(endpoint, data);
+ break;
+ case 'put':
+ result = await api.put(endpoint, data);
+ break;
+ case 'patch':
+ result = await api.patch(endpoint, data);
+ break;
+ case 'delete':
+ result = await api.delete(endpoint);
+ break;
+ }
+ setState({ data: result as unknown as T, loading: false, error: null });
+ return result;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Une erreur est survenue';
setState(prev => ({ ...prev, loading: false, error: message }));
@@ -97,7 +90,8 @@ interface UseFetchDataReturn {
}
/**
- * Hook for fetching data on mount with auto-refresh support
+ * Hook for fetching data with auto-refresh support
+ * Uses centralized api service from services/api.ts
*/
export function useFetchData(
endpoint: string,
@@ -112,7 +106,7 @@ export function useFetchData(
setError(null);
try {
- const result = await apiFetch<{ data: T } | T>(endpoint);
+ const result = await api.get<{ data: T } | T>(endpoint);
const responseData = (result as { data: T }).data ?? result as T;
setData(responseData);
options?.onSuccess?.(responseData);
diff --git a/utils/theme.ts b/utils/theme.ts
new file mode 100644
index 0000000..1223e03
--- /dev/null
+++ b/utils/theme.ts
@@ -0,0 +1,102 @@
+/**
+ * Centralized theme and color utility
+ * This file consolidates all color schemes and styling constants
+ * to eliminate duplication across components.
+ */
+
+// Status colors for badges and indicators
+export const STATUS_COLORS = {
+ PENDING: 'bg-yellow-100 text-yellow-800',
+ CLAIMED: 'bg-green-100 text-green-800',
+ REJECTED: 'bg-red-100 text-red-800',
+ ACTIVE: 'bg-green-100 text-green-800',
+ INACTIVE: 'bg-gray-100 text-gray-800',
+ EXPIRED: 'bg-red-100 text-red-800',
+} as const;
+
+// Badge color variants
+export const BADGE_COLORS = {
+ info: 'bg-blue-100 text-blue-800',
+ success: 'bg-green-100 text-green-800',
+ warning: 'bg-yellow-100 text-yellow-800',
+ error: 'bg-red-100 text-red-800',
+ purple: 'bg-purple-100 text-purple-800',
+ pink: 'bg-pink-100 text-pink-800',
+ amber: 'bg-amber-100 text-amber-800',
+ gray: 'bg-gray-100 text-gray-800',
+} as const;
+
+// Button style variants
+export const BUTTON_STYLES = {
+ primary: 'bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-all duration-300',
+ secondary: 'bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition-all duration-300',
+ success: 'bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 transition-all duration-300',
+ danger: 'bg-red-600 text-white px-6 py-2 rounded-lg hover:bg-red-700 transition-all duration-300',
+ warning: 'bg-yellow-600 text-white px-6 py-2 rounded-lg hover:bg-yellow-700 transition-all duration-300',
+ outline: 'border border-blue-600 text-blue-600 px-6 py-2 rounded-lg hover:bg-blue-50 transition-all duration-300',
+ ghost: 'text-gray-600 px-6 py-2 rounded-lg hover:bg-gray-100 transition-all duration-300',
+} as const;
+
+// Common input styling
+export const INPUT_STYLES = {
+ base: 'w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-gray-100 disabled:cursor-not-allowed',
+ error: 'border-red-500',
+ normal: 'border-gray-300',
+} as const;
+
+// Card and container styling
+export const CARD_STYLES = {
+ base: 'bg-white rounded-lg shadow-md p-6',
+ hover: 'bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow duration-300',
+ bordered: 'bg-white rounded-lg border border-gray-200 p-6',
+} as const;
+
+// Alert/notification styles
+export const ALERT_STYLES = {
+ info: 'bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded-lg',
+ success: 'bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg',
+ warning: 'bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-3 rounded-lg',
+ error: 'bg-red-50 border border-red-400 text-red-700 px-4 py-3 rounded-lg',
+} as const;
+
+// Utility function to get status color
+export function getStatusColor(status: string): string {
+ const upperStatus = status.toUpperCase();
+ return STATUS_COLORS[upperStatus as keyof typeof STATUS_COLORS] || BADGE_COLORS.gray;
+}
+
+// Utility function to get button style
+export function getButtonStyle(variant: keyof typeof BUTTON_STYLES = 'primary'): string {
+ return BUTTON_STYLES[variant];
+}
+
+// Utility function to get input class with error state
+export function getInputStyle(hasError: boolean): string {
+ return `${INPUT_STYLES.base} ${hasError ? INPUT_STYLES.error : INPUT_STYLES.normal}`;
+}
+
+// Role-based colors for user badges
+export const ROLE_COLORS = {
+ ADMIN: 'bg-red-100 text-red-800',
+ EMPLOYEE: 'bg-blue-100 text-blue-800',
+ CLIENT: 'bg-green-100 text-green-800',
+} as const;
+
+export function getRoleColor(role: string): string {
+ const upperRole = role.toUpperCase();
+ return ROLE_COLORS[upperRole as keyof typeof ROLE_COLORS] || BADGE_COLORS.gray;
+}
+
+// Prize type colors (from constants.ts PRIZE_CONFIG)
+export const PRIZE_COLORS = {
+ INFUSEUR: 'bg-blue-100 text-blue-800',
+ THE_SIGNATURE: 'bg-green-100 text-green-800',
+ COFFRET_DECOUVERTE: 'bg-purple-100 text-purple-800',
+ COFFRET_PRESTIGE: 'bg-amber-100 text-amber-800',
+ THE_GRATUIT: 'bg-pink-100 text-pink-800',
+} as const;
+
+export function getPrizeColor(prizeType: string): string {
+ const upperType = prizeType.toUpperCase();
+ return PRIZE_COLORS[upperType as keyof typeof PRIZE_COLORS] || BADGE_COLORS.gray;
+}