diff --git a/__tests__/components/UserDropdown.test.tsx b/__tests__/components/UserDropdown.test.tsx
new file mode 100644
index 0000000..c13b978
--- /dev/null
+++ b/__tests__/components/UserDropdown.test.tsx
@@ -0,0 +1,211 @@
+/**
+ * Tests for the UserDropdown component
+ * @jest-environment jsdom
+ */
+
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { UserDropdown } from '@/components/UserDropdown';
+
+// Mock next/link
+jest.mock('next/link', () => {
+ return ({ children, href }: { children: React.ReactNode; href: string }) => (
+ {children}
+ );
+});
+
+describe('UserDropdown', () => {
+ const mockUser = {
+ firstName: 'John',
+ lastName: 'Doe',
+ email: 'john.doe@example.com',
+ };
+
+ const mockOnLogout = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render user initials', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('JD')).toBeInTheDocument();
+ });
+
+ it('should render user name and email', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
+ expect(screen.getByText('john.doe@example.com')).toBeInTheDocument();
+ });
+
+ it('should toggle dropdown on click', () => {
+ render(
+
+ );
+
+ // Dropdown should be closed initially
+ expect(screen.queryByText('Profil')).not.toBeInTheDocument();
+
+ // Click to open - get the main trigger button (contains user name)
+ const triggerButton = screen.getByText('John Doe').closest('button');
+ fireEvent.click(triggerButton!);
+ expect(screen.getByText('Profil')).toBeInTheDocument();
+ expect(screen.getByText('Deconnexion')).toBeInTheDocument();
+
+ // Click to close
+ fireEvent.click(triggerButton!);
+ expect(screen.queryByText('Profil')).not.toBeInTheDocument();
+ });
+
+ it('should render profile link with correct path', () => {
+ render(
+
+ );
+
+ const triggerButton = screen.getByText('John Doe').closest('button');
+ fireEvent.click(triggerButton!);
+
+ const profileLink = screen.getByText('Profil').closest('a');
+ expect(profileLink).toHaveAttribute('href', '/admin/profil');
+ });
+
+ it('should call onLogout when logout button is clicked', () => {
+ render(
+
+ );
+
+ const triggerButton = screen.getByText('John Doe').closest('button');
+ fireEvent.click(triggerButton!);
+ fireEvent.click(screen.getByText('Deconnexion'));
+
+ expect(mockOnLogout).toHaveBeenCalledTimes(1);
+ });
+
+ it('should have onClick handler on profile link', () => {
+ render(
+
+ );
+
+ const triggerButton = screen.getByText('John Doe').closest('button');
+ fireEvent.click(triggerButton!);
+
+ const profileLink = screen.getByText('Profil').closest('a');
+ expect(profileLink).toBeInTheDocument();
+ expect(profileLink).toHaveAttribute('href', '/profil');
+ });
+
+ it('should apply blue accent color by default', () => {
+ const { container } = render(
+
+ );
+
+ const avatar = container.querySelector('.bg-blue-600');
+ expect(avatar).toBeInTheDocument();
+ });
+
+ it('should apply green accent color when specified', () => {
+ const { container } = render(
+
+ );
+
+ const avatar = container.querySelector('.bg-green-600');
+ expect(avatar).toBeInTheDocument();
+ });
+
+ it('should handle null user gracefully', () => {
+ const { container } = render(
+
+ );
+
+ // Should render empty initials
+ const avatarDiv = container.querySelector('.bg-blue-600');
+ expect(avatarDiv).toBeInTheDocument();
+ });
+
+ it('should handle user with missing firstName', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('D')).toBeInTheDocument();
+ });
+
+ it('should apply custom className', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toHaveClass('custom-class');
+ });
+
+ it('should rotate chevron icon when dropdown is open', () => {
+ const { container } = render(
+
+ );
+
+ const chevron = container.querySelector('.w-4.h-4.text-gray-500');
+ expect(chevron).not.toHaveClass('rotate-180');
+
+ const triggerButton = screen.getByText('John Doe').closest('button');
+ fireEvent.click(triggerButton!);
+
+ const rotatedChevron = container.querySelector('.rotate-180');
+ expect(rotatedChevron).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/ui/TicketPrizeDisplay.test.tsx b/__tests__/components/ui/TicketPrizeDisplay.test.tsx
new file mode 100644
index 0000000..3155e15
--- /dev/null
+++ b/__tests__/components/ui/TicketPrizeDisplay.test.tsx
@@ -0,0 +1,73 @@
+/**
+ * Tests for the TicketPrizeDisplay component
+ * @jest-environment jsdom
+ */
+
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { TicketPrizeDisplay } from '@/components/ui/TicketPrizeDisplay';
+
+describe('TicketPrizeDisplay', () => {
+ it('should render prize name for INFUSEUR', () => {
+ render();
+ expect(screen.getByText('Infuseur à thé')).toBeInTheDocument();
+ });
+
+ it('should render prize name for THE_SIGNATURE', () => {
+ render();
+ expect(screen.getByText('Thé signature 100g')).toBeInTheDocument();
+ });
+
+ it('should render prize name for COFFRET_DECOUVERTE', () => {
+ render();
+ expect(screen.getByText('Coffret découverte 39€')).toBeInTheDocument();
+ });
+
+ it('should render prize name for COFFRET_PRESTIGE', () => {
+ render();
+ expect(screen.getByText('Coffret prestige 69€')).toBeInTheDocument();
+ });
+
+ it('should render prize name for THE_GRATUIT', () => {
+ render();
+ expect(screen.getByText('Thé gratuit en magasin')).toBeInTheDocument();
+ });
+
+ it('should render icon for each prize type', () => {
+ const { container } = render();
+ const svg = container.querySelector('svg');
+ expect(svg).toBeInTheDocument();
+ });
+
+ it('should return null for unknown prize type', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should apply custom className', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toHaveClass('custom-class');
+ });
+
+ it('should render with prize color styling', () => {
+ const { container } = render();
+ const iconContainer = container.querySelector('.rounded-full');
+ expect(iconContainer).toBeInTheDocument();
+ });
+
+ it('should have flex layout with gap', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('flex');
+ expect(container.firstChild).toHaveClass('items-center');
+ expect(container.firstChild).toHaveClass('gap-3');
+ });
+
+ it('should render prize name with correct text styling', () => {
+ render();
+ const nameElement = screen.getByText('Infuseur à thé');
+ expect(nameElement).toHaveClass('text-sm');
+ expect(nameElement).toHaveClass('font-medium');
+ });
+});
diff --git a/__tests__/components/ui/TicketTableRow.test.tsx b/__tests__/components/ui/TicketTableRow.test.tsx
new file mode 100644
index 0000000..36c7fe1
--- /dev/null
+++ b/__tests__/components/ui/TicketTableRow.test.tsx
@@ -0,0 +1,194 @@
+/**
+ * Tests for the TicketTableRow component
+ * @jest-environment jsdom
+ */
+
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { TicketTableRow } from '@/components/ui/TicketTableRow';
+import { Ticket } from '@/types';
+
+describe('TicketTableRow', () => {
+ const mockTicket: Ticket = {
+ id: '1',
+ code: 'ABC123',
+ status: 'PENDING',
+ playedAt: '2024-01-15T10:30:00Z',
+ claimedAt: null,
+ prize: {
+ id: '1',
+ type: 'INFUSEUR',
+ name: 'Infuseur à thé',
+ value: 10,
+ },
+ };
+
+ it('should render ticket code', () => {
+ render(
+
+ );
+ expect(screen.getByText('ABC123')).toBeInTheDocument();
+ });
+
+ it('should render ticket status badge', () => {
+ render(
+
+ );
+ expect(screen.getByText('En attente')).toBeInTheDocument();
+ });
+
+ it('should render prize display when prize exists', () => {
+ render(
+
+ );
+ expect(screen.getByText('Infuseur à thé')).toBeInTheDocument();
+ });
+
+ it('should render played date', () => {
+ render(
+
+ );
+ // French date format
+ expect(screen.getByText(/15\/01\/2024|15 janvier 2024/)).toBeInTheDocument();
+ });
+
+ it('should show claimed date when showClaimedDate is true', () => {
+ const ticketWithClaim: Ticket = {
+ ...mockTicket,
+ status: 'CLAIMED',
+ claimedAt: '2024-01-20T14:00:00Z',
+ };
+
+ render(
+
+ );
+
+ // Should have an extra column for claimed date
+ const cells = screen.getAllByRole('cell');
+ expect(cells.length).toBe(5); // code, prize, status, playedAt, claimedAt
+ });
+
+ it('should not show claimed date column when showClaimedDate is false', () => {
+ render(
+
+ );
+
+ const cells = screen.getAllByRole('cell');
+ expect(cells.length).toBe(4); // code, prize, status, playedAt
+ });
+
+ it('should show dash when playedAt is null', () => {
+ const ticketWithoutDate: Ticket = {
+ ...mockTicket,
+ playedAt: null,
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByText('-')).toBeInTheDocument();
+ });
+
+ it('should show dash when claimedAt is null with showClaimedDate', () => {
+ render(
+
+ );
+
+ const dashes = screen.getAllByText('-');
+ expect(dashes.length).toBeGreaterThan(0);
+ });
+
+ it('should render CLAIMED status correctly', () => {
+ const claimedTicket: Ticket = {
+ ...mockTicket,
+ status: 'CLAIMED',
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByText('Réclamé')).toBeInTheDocument();
+ });
+
+ it('should handle ticket without prize', () => {
+ const ticketWithoutPrize: Ticket = {
+ ...mockTicket,
+ prize: undefined,
+ };
+
+ render(
+
+ );
+
+ expect(screen.getByText('ABC123')).toBeInTheDocument();
+ // Should not crash and prize cell should be empty
+ });
+
+ it('should have hover styling on row', () => {
+ render(
+
+ );
+
+ const row = screen.getByRole('row');
+ expect(row).toHaveClass('hover:bg-gradient-to-r');
+ });
+
+ it('should render code with monospace font', () => {
+ render(
+
+ );
+
+ const codeElement = screen.getByText('ABC123');
+ expect(codeElement).toHaveClass('font-mono');
+ });
+});
diff --git a/__tests__/hooks/useClickOutside.test.ts b/__tests__/hooks/useClickOutside.test.ts
new file mode 100644
index 0000000..448a4cc
--- /dev/null
+++ b/__tests__/hooks/useClickOutside.test.ts
@@ -0,0 +1,103 @@
+/**
+ * Tests for the useClickOutside hook
+ * @jest-environment jsdom
+ */
+
+import { renderHook } from '@testing-library/react';
+import { useClickOutside } from '@/hooks/useClickOutside';
+import { createRef } from 'react';
+
+describe('useClickOutside', () => {
+ let callback: jest.Mock;
+ let ref: React.RefObject;
+ let element: HTMLDivElement;
+
+ beforeEach(() => {
+ callback = jest.fn();
+ element = document.createElement('div');
+ document.body.appendChild(element);
+ ref = { current: element };
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ jest.clearAllMocks();
+ });
+
+ it('should call callback when clicking outside the element', () => {
+ renderHook(() => useClickOutside(ref, callback, true));
+
+ const outsideElement = document.createElement('div');
+ document.body.appendChild(outsideElement);
+
+ const event = new MouseEvent('mousedown', { bubbles: true });
+ outsideElement.dispatchEvent(event);
+
+ expect(callback).toHaveBeenCalledTimes(1);
+
+ document.body.removeChild(outsideElement);
+ });
+
+ it('should not call callback when clicking inside the element', () => {
+ renderHook(() => useClickOutside(ref, callback, true));
+
+ const event = new MouseEvent('mousedown', { bubbles: true });
+ element.dispatchEvent(event);
+
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it('should not call callback when disabled', () => {
+ renderHook(() => useClickOutside(ref, callback, false));
+
+ const outsideElement = document.createElement('div');
+ document.body.appendChild(outsideElement);
+
+ const event = new MouseEvent('mousedown', { bubbles: true });
+ outsideElement.dispatchEvent(event);
+
+ expect(callback).not.toHaveBeenCalled();
+
+ document.body.removeChild(outsideElement);
+ });
+
+ it('should handle null ref gracefully', () => {
+ const nullRef = createRef();
+
+ // Should not throw when ref is null
+ expect(() => {
+ renderHook(() => useClickOutside(nullRef, callback, true));
+
+ const event = new MouseEvent('mousedown', { bubbles: true });
+ document.body.dispatchEvent(event);
+ }).not.toThrow();
+
+ // Callback should NOT be called when ref is null (ref.current is falsy)
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it('should remove event listener on unmount', () => {
+ const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener');
+
+ const { unmount } = renderHook(() => useClickOutside(ref, callback, true));
+ unmount();
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
+
+ removeEventListenerSpy.mockRestore();
+ });
+
+ it('should use default enabled value of true', () => {
+ renderHook(() => useClickOutside(ref, callback));
+
+ const outsideElement = document.createElement('div');
+ document.body.appendChild(outsideElement);
+
+ const event = new MouseEvent('mousedown', { bubbles: true });
+ outsideElement.dispatchEvent(event);
+
+ expect(callback).toHaveBeenCalledTimes(1);
+
+ document.body.removeChild(outsideElement);
+ });
+});