Skip to content

🛠️ Développement Frontend

📋 Vue d'ensemble

Le frontend LIPAIX est construit avec les technologies modernes du web : React 18, Next.js 13+ avec App Router, et TailwindCSS pour un système de design cohérent et responsive.

🏗️ Architecture frontend

Stack technologique

  • React 18 - Bibliothèque de composants avec hooks avancés
  • Next.js 13+ - Framework React avec App Router
  • TailwindCSS - Framework CSS utility-first
  • TypeScript - Typage statique pour la robustesse
  • Framer Motion - Animations fluides
  • Zustand - Gestion d'état légère
  • React Query - Gestion des données serveur

Structure des composants

Frontend Architecture
├── 🎨 UI Components
│   ├── Atoms (Button, Input, Card)
│   ├── Molecules (Form, Navigation)
│   └── Organisms (Header, Footer, Layout)
├── 📱 Pages & Routes
│   ├── App Router (Next.js 13+)
│   ├── Server Components
│   └── Client Components
├── 🎯 State Management
│   ├── Local State (useState, useReducer)
│   ├── Global State (Zustand)
│   └── Server State (React Query)
└── 🎨 Styling System
    ├── TailwindCSS Utilities
    ├── Design System
    └── Responsive Design

🚀 Concepts fondamentaux

React 18 - Composants et hooks

React est une bibliothèque JavaScript pour créer des interfaces utilisateur interactives.

Composants fonctionnels :

tsx
// Composant fonctionnel simple
function WelcomeMessage({ name }: { name: string }) {
  return (
    <div className="text-center py-8">
      <h1 className="text-3xl font-bold text-gray-900">
        Bienvenue, {name} ! 🎭
      </h1>
      <p className="text-lg text-gray-600 mt-2">
        Prêt pour l'improvisation ?
      </p>
    </div>
  );
}

// Utilisation
<WelcomeMessage name="Marie" />

Hooks React :

tsx
// Hook useState pour l'état local
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div className="flex items-center gap-4">
      <span className="text-2xl font-bold">{count}</span>
      <button
        onClick={() => setCount(count + 1)}
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        Incrémenter
      </button>
    </div>
  );
}

// Hook useEffect pour les effets de bord
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function fetchUser() {
      try {
        const response = await fetch(`/api/users/${userId}`);
        const userData = await response.json();
        setUser(userData);
      } catch (error) {
        console.error('Erreur lors de la récupération de l\'utilisateur:', error);
      } finally {
        setLoading(false);
      }
    }

    fetchUser();
  }, [userId]);

  if (loading) return <div>Chargement...</div>;
  if (!user) return <div>Utilisateur non trouvé</div>;

  return (
    <div className="p-6 bg-white rounded-lg shadow">
      <h2 className="text-xl font-semibold">{user.name}</h2>
      <p className="text-gray-600">{user.email}</p>
    </div>
  );
}

Next.js 13+ - App Router

Next.js est un framework React qui simplifie le développement d'applications web modernes.

Structure App Router :

app/
├── 📁 layout.tsx          # Layout racine
├── 📁 page.tsx            # Page d'accueil
├── 📁 globals.css         # Styles globaux
├── 📁 shows/
│   ├── 📁 page.tsx        # Liste des spectacles
│   ├── 📁 [id]/
│   │   └── 📁 page.tsx    # Détail d'un spectacle
│   └── 📁 new/
│       └── 📁 page.tsx    # Créer un spectacle
├── 📁 admin/
│   ├── 📁 layout.tsx      # Layout admin
│   └── 📁 page.tsx        # Dashboard admin
└── 📁 api/
    ├── 📁 shows/
    │   └── 📁 route.ts    # API spectacles
    └── 📁 users/
        └── 📁 route.ts    # API utilisateurs

Layout racine :

tsx
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'LIPAIX - Théâtre d\'improvisation',
  description: 'Découvrez nos spectacles et événements d\'improvisation',
  keywords: ['théâtre', 'improvisation', 'spectacle', 'LIPAIX'],
  authors: [{ name: 'LIPAIX Team' }],
  openGraph: {
    title: 'LIPAIX - Théâtre d\'improvisation',
    description: 'Découvrez nos spectacles et événements d\'improvisation',
    url: 'https://www.lipaix.com',
    siteName: 'LIPAIX',
    images: [
      {
        url: '/og-image.jpg',
        width: 1200,
        height: 630,
        alt: 'LIPAIX - Théâtre d\'improvisation'
      }
    ],
    locale: 'fr_FR',
    type: 'website'
  },
  twitter: {
    card: 'summary_large_image',
    title: 'LIPAIX - Théâtre d\'improvisation',
    description: 'Découvrez nos spectacles et événements d\'improvisation',
    images: ['/og-image.jpg']
  }
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="fr">
      <body className={inter.className}>
        <Header />
        <main className="min-h-screen">
          {children}
        </main>
        <Footer />
      </body>
    </html>
  );
}

Page dynamique :

tsx
// app/shows/[id]/page.tsx
import { notFound } from 'next/navigation';
import { getShowById } from '@/lib/shows';

interface ShowPageProps {
  params: {
    id: string;
  };
}

export default async function ShowPage({ params }: ShowPageProps) {
  const show = await getShowById(params.id);

  if (!show) {
    notFound();
  }

  return (
    <div className="container mx-auto px-4 py-8">
      <div className="max-w-4xl mx-auto">
        <header className="mb-8">
          <h1 className="text-4xl font-bold text-gray-900 mb-4">
            {show.title}
          </h1>
          <div className="flex items-center gap-4 text-gray-600">
            <span>📅 {new Date(show.date).toLocaleDateString('fr-FR')}</span>
            <span>👥 {show.maxPlayers} joueurs max</span>
            <span className={`px-2 py-1 rounded text-sm ${
              show.status === 'published' ? 'bg-green-100 text-green-800' :
              show.status === 'draft' ? 'bg-gray-100 text-gray-800' :
              'bg-red-100 text-red-800'
            }`}>
              {show.status}
            </span>
          </div>
        </header>

        <div className="prose prose-lg max-w-none">
          <div dangerouslySetInnerHTML={{ __html: show.description }} />
        </div>

        <div className="mt-8 p-6 bg-gray-50 rounded-lg">
          <h2 className="text-2xl font-semibold mb-4">Participer</h2>
          <p className="text-gray-600 mb-4">
            Intéressé par ce spectacle ? Indiquez votre disponibilité !
          </p>
          <button className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
            Indiquer ma disponibilité
          </button>
        </div>
      </div>
    </div>
  );
}

TailwindCSS - Système de design

TailwindCSS est un framework CSS utility-first qui permet de construire des interfaces rapidement.

Classes utilitaires :

tsx
// Composant avec TailwindCSS
function EventCard({ event }: { event: Event }) {
  return (
    <div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300">
      {/* Image de l'événement */}
      <div className="aspect-video bg-gradient-to-br from-blue-400 to-purple-600 flex items-center justify-center">
        <span className="text-white text-4xl">🎭</span>
      </div>
      
      {/* Contenu */}
      <div className="p-6">
        <h3 className="text-xl font-semibold text-gray-900 mb-2 line-clamp-2">
          {event.title}
        </h3>
        
        <p className="text-gray-600 text-sm mb-4 line-clamp-3">
          {event.description}
        </p>
        
        {/* Métadonnées */}
        <div className="flex items-center justify-between text-sm text-gray-500 mb-4">
          <span className="flex items-center gap-1">
            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
            </svg>
            {new Date(event.date).toLocaleDateString('fr-FR')}
          </span>
          
          <span className="flex items-center gap-1">
            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
            </svg>
            {event.maxPlayers} joueurs
          </span>
        </div>
        
        {/* Actions */}
        <div className="flex gap-2">
          <button className="flex-1 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
            Voir détails
          </button>
          
          <button className="px-4 py-2 border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
            </svg>
          </button>
        </div>
      </div>
    </div>
  );
}

Système de couleurs personnalisé :

css
/* tailwind.config.js */
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: {
          50: '#eff6ff',
          100: '#dbeafe',
          200: '#bfdbfe',
          300: '#93c5fd',
          400: '#60a5fa',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
          800: '#1e40af',
          900: '#1e3a8a',
        },
        secondary: {
          50: '#fdf4ff',
          100: '#fae8ff',
          200: '#f5d0fe',
          300: '#f0abfc',
          400: '#e879f9',
          500: '#d946ef',
          600: '#c026d3',
          700: '#a21caf',
          800: '#86198f',
          900: '#701a75',
        }
      }
    }
  }
}

🔄 Gestion d'état

État local avec React

tsx
// Gestion d'état local complexe
function EventForm() {
  const [formData, setFormData] = useState({
    title: '',
    description: '',
    date: '',
    maxPlayers: 1,
    status: 'draft'
  });

  const [errors, setErrors] = useState<Record<string, string>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleInputChange = (field: string, value: string | number) => {
    setFormData(prev => ({ ...prev, [field]: value }));
    
    // Effacer l'erreur du champ modifié
    if (errors[field]) {
      setErrors(prev => ({ ...prev, [field]: '' }));
    }
  };

  const validateForm = () => {
    const newErrors: Record<string, string> = {};

    if (!formData.title.trim()) {
      newErrors.title = 'Le titre est requis';
    }

    if (!formData.description.trim()) {
      newErrors.description = 'La description est requise';
    }

    if (!formData.date) {
      newErrors.date = 'La date est requise';
    }

    if (formData.maxPlayers < 1) {
      newErrors.maxPlayers = 'Le nombre de joueurs doit être positif';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!validateForm()) return;

    setIsSubmitting(true);
    
    try {
      const response = await fetch('/api/shows', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData)
      });

      if (response.ok) {
        // Redirection ou notification de succès
        alert('Événement créé avec succès !');
      } else {
        throw new Error('Erreur lors de la création');
      }
    } catch (error) {
      console.error('Erreur:', error);
      alert('Erreur lors de la création de l\'événement');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div>
        <label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
          Titre de l'événement *
        </label>
        <input
          type="text"
          id="title"
          value={formData.title}
          onChange={(e) => handleInputChange('title', e.target.value)}
          className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
            errors.title ? 'border-red-500' : 'border-gray-300'
          }`}
          placeholder="Ex: Match d'improvisation"
        />
        {errors.title && (
          <p className="mt-1 text-sm text-red-600">{errors.title}</p>
        )}
      </div>

      <div>
        <label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
          Description *
        </label>
        <textarea
          id="description"
          rows={4}
          value={formData.description}
          onChange={(e) => handleInputChange('description', e.target.value)}
          className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
            errors.description ? 'border-red-500' : 'border-gray-300'
          }`}
          placeholder="Décrivez votre événement..."
        />
        {errors.description && (
          <p className="mt-1 text-sm text-red-600">{errors.description}</p>
        )}
      </div>

      <div className="grid grid-cols-2 gap-4">
        <div>
          <label htmlFor="date" className="block text-sm font-medium text-gray-700 mb-2">
            Date et heure *
          </label>
          <input
            type="datetime-local"
            id="date"
            value={formData.date}
            onChange={(e) => handleInputChange('date', e.target.value)}
            className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
              errors.date ? 'border-red-500' : 'border-gray-300'
            }`}
          />
          {errors.date && (
            <p className="mt-1 text-sm text-red-600">{errors.date}</p>
          )}
        </div>

        <div>
          <label htmlFor="maxPlayers" className="block text-sm font-medium text-gray-700 mb-2">
            Nombre max de joueurs *
          </label>
          <input
            type="number"
            id="maxPlayers"
            min="1"
            max="50"
            value={formData.maxPlayers}
            onChange={(e) => handleInputChange('maxPlayers', parseInt(e.target.value))}
            className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
              errors.maxPlayers ? 'border-red-500' : 'border-gray-300'
            }`}
          />
          {errors.maxPlayers && (
            <p className="mt-1 text-sm text-red-600">{errors.maxPlayers}</p>
          )}
        </div>
      </div>

      <div className="flex justify-end gap-4">
        <button
          type="button"
          onClick={() => setFormData({
            title: '',
            description: '',
            date: '',
            maxPlayers: 1,
            status: 'draft'
          })}
          className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
        >
          Réinitialiser
        </button>
        
        <button
          type="submit"
          disabled={isSubmitting}
          className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
        >
          {isSubmitting ? 'Création...' : 'Créer l\'événement'}
        </button>
      </div>
    </form>
  );
}

État global avec Zustand

tsx
// Store Zustand pour la gestion des événements
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface Event {
  id: string;
  title: string;
  description: string;
  date: string;
  maxPlayers: number;
  status: 'draft' | 'published' | 'cancelled';
}

interface EventStore {
  events: Event[];
  loading: boolean;
  error: string | null;
  
  // Actions
  fetchEvents: () => Promise<void>;
  addEvent: (event: Omit<Event, 'id'>) => Promise<void>;
  updateEvent: (id: string, updates: Partial<Event>) => Promise<void>;
  deleteEvent: (id: string) => Promise<void>;
  
  // Getters
  getEventById: (id: string) => Event | undefined;
  getUpcomingEvents: () => Event[];
  getPublishedEvents: () => Event[];
}

export const useEventStore = create<EventStore>()(
  devtools(
    (set, get) => ({
      events: [],
      loading: false,
      error: null,

      fetchEvents: async () => {
        set({ loading: true, error: null });
        
        try {
          const response = await fetch('/api/shows');
          const data = await response.json();
          
          set({ events: data, loading: false });
        } catch (error) {
          set({ 
            error: 'Erreur lors de la récupération des événements', 
            loading: false 
          });
        }
      },

      addEvent: async (eventData) => {
        try {
          const response = await fetch('/api/shows', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(eventData)
          });

          const newEvent = await response.json();
          
          set(state => ({
            events: [...state.events, newEvent]
          }));
        } catch (error) {
          set({ error: 'Erreur lors de la création de l\'événement' });
        }
      },

      updateEvent: async (id, updates) => {
        try {
          const response = await fetch(`/api/shows/${id}`, {
            method: 'PATCH',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(updates)
          });

          const updatedEvent = await response.json();
          
          set(state => ({
            events: state.events.map(event =>
              event.id === id ? { ...event, ...updatedEvent } : event
            )
          }));
        } catch (error) {
          set({ error: 'Erreur lors de la mise à jour de l\'événement' });
        }
      },

      deleteEvent: async (id) => {
        try {
          await fetch(`/api/shows/${id}`, { method: 'DELETE' });
          
          set(state => ({
            events: state.events.filter(event => event.id !== id)
          }));
        } catch (error) {
          set({ error: 'Erreur lors de la suppression de l\'événement' });
        }
      },

      getEventById: (id) => {
        return get().events.find(event => event.id === id);
      },

      getUpcomingEvents: () => {
        const now = new Date();
        return get().events.filter(event => 
          new Date(event.date) > now
        );
      },

      getPublishedEvents: () => {
        return get().events.filter(event => event.status === 'published');
      }
    }),
    { name: 'event-store' }
  )
);

// Utilisation du store
function EventList() {
  const { events, loading, error, fetchEvents } = useEventStore();

  useEffect(() => {
    fetchEvents();
  }, [fetchEvents]);

  if (loading) return <div>Chargement des événements...</div>;
  if (error) return <div className="text-red-600">Erreur: {error}</div>;

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      {events.map(event => (
        <EventCard key={event.id} event={event} />
      ))}
    </div>
  );
}

🎨 Système de design

Composants réutilisables

tsx
// Composant Button réutilisable
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
  children: React.ReactNode;
}

export function Button({ 
  variant = 'primary', 
  size = 'md', 
  loading = false,
  children, 
  className = '',
  disabled,
  ...props 
}: ButtonProps) {
  const baseClasses = 'inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
  
  const variantClasses = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
    secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500',
    outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-blue-500',
    ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500'
  };
  
  const sizeClasses = {
    sm: 'px-3 py-1.5 text-sm',
    md: 'px-4 py-2 text-sm',
    lg: 'px-6 py-3 text-base'
  };

  return (
    <button
      className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
      disabled={disabled || loading}
      {...props}
    >
      {loading && (
        <svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
          <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
          <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
        </svg>
      )}
      {children}
    </button>
  );
}

// Utilisation
<Button variant="primary" size="lg" loading={isSubmitting}>
  Créer l'événement
</Button>

<Button variant="outline" size="sm">
  Annuler
</Button>

Layout et navigation

tsx
// Composant Header
export function Header() {
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const [isScrolled, setIsScrolled] = useState(false);

  useEffect(() => {
    const handleScroll = () => {
      setIsScrolled(window.scrollY > 10);
    };

    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return (
    <header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
      isScrolled ? 'bg-white/95 backdrop-blur-sm shadow-md' : 'bg-transparent'
    }`}>
      <nav className="container mx-auto px-4">
        <div className="flex items-center justify-between h-16">
          {/* Logo */}
          <Link href="/" className="flex items-center space-x-2">
            <span className="text-2xl">🎭</span>
            <span className="text-xl font-bold text-gray-900">LIPAIX</span>
          </Link>

          {/* Navigation desktop */}
          <div className="hidden md:flex items-center space-x-8">
            <Link href="/shows" className="text-gray-700 hover:text-blue-600 transition-colors">
              Spectacles
            </Link>
            <Link href="/about" className="text-gray-700 hover:text-blue-600 transition-colors">
              À propos
            </Link>
            <Link href="/contact" className="text-gray-700 hover:text-blue-600 transition-colors">
              Contact
            </Link>
            <Link href="/admin" className="text-gray-700 hover:text-blue-600 transition-colors">
              Admin
            </Link>
          </div>

          {/* Bouton mobile */}
          <button
            onClick={() => setIsMenuOpen(!isMenuOpen)}
            className="md:hidden p-2 rounded-md text-gray-700 hover:text-blue-600 hover:bg-gray-100"
          >
            <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
            </svg>
          </button>
        </div>

        {/* Menu mobile */}
        {isMenuOpen && (
          <div className="md:hidden">
            <div className="px-2 pt-2 pb-3 space-y-1 bg-white border-t">
              <Link
                href="/shows"
                className="block px-3 py-2 text-gray-700 hover:text-blue-600 hover:bg-gray-50 rounded-md"
                onClick={() => setIsMenuOpen(false)}
              >
                Spectacles
              </Link>
              <Link
                href="/about"
                className="block px-3 py-2 text-gray-700 hover:text-blue-600 hover:bg-gray-50 rounded-md"
                onClick={() => setIsMenuOpen(false)}
              >
                À propos
              </Link>
              <Link
                href="/contact"
                className="block px-3 py-2 text-gray-700 hover:text-blue-600 hover:bg-gray-50 rounded-md"
                onClick={() => setIsMenuOpen(false)}
              >
                Contact
              </Link>
              <Link
                href="/admin"
                className="block px-3 py-2 text-gray-700 hover:text-blue-600 hover:bg-gray-50 rounded-md"
                onClick={() => setIsMenuOpen(false)}
              >
                Admin
              </Link>
            </div>
          </div>
        )}
      </nav>
    </header>
  );
}

🚀 Performance et optimisation

Lazy loading et code splitting

tsx
// Lazy loading des composants
import dynamic from 'next/dynamic';

// Charger le composant AdminPanel seulement quand nécessaire
const AdminPanel = dynamic(() => import('@/components/AdminPanel'), {
  loading: () => <div>Chargement de l'admin...</div>,
  ssr: false // Désactiver le rendu côté serveur pour ce composant
});

// Charger le composant Chart avec un délai
const Chart = dynamic(() => import('@/components/Chart'), {
  loading: () => <div>Chargement du graphique...</div>,
  ssr: false
});

// Page avec composants lazy
export default function DashboardPage() {
  const [showAdmin, setShowAdmin] = useState(false);
  const [showChart, setShowChart] = useState(false);

  return (
    <div className="p-6">
      <h1 className="text-3xl font-bold mb-6">Tableau de bord</h1>
      
      <div className="space-y-6">
        <button
          onClick={() => setShowAdmin(!showAdmin)}
          className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
        >
          {showAdmin ? 'Masquer' : 'Afficher'} l'admin
        </button>
        
        {showAdmin && <AdminPanel />}
        
        <button
          onClick={() => setShowChart(!showChart)}
          className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
        >
          {showChart ? 'Masquer' : 'Afficher'} le graphique
        </button>
        
        {showChart && <Chart />}
      </div>
    </div>
  );
}

Optimisation des images

tsx
// Composant Image optimisé
import Image from 'next/image';

function EventImage({ event }: { event: Event }) {
  return (
    <div className="relative aspect-video overflow-hidden rounded-lg">
      {event.imageUrl ? (
        <Image
          src={event.imageUrl}
          alt={event.title}
          fill
          className="object-cover"
          sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
          priority={false}
          placeholder="blur"
          blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxAAPwCdABmX/9k="
        />
      ) : (
        <div className="w-full h-full bg-gradient-to-br from-blue-400 to-purple-600 flex items-center justify-center">
          <span className="text-white text-4xl">🎭</span>
        </div>
      )}
      
      {/* Badge de statut */}
      <div className="absolute top-2 right-2">
        <span className={`px-2 py-1 text-xs font-medium rounded-full ${
          event.status === 'published' ? 'bg-green-100 text-green-800' :
          event.status === 'draft' ? 'bg-gray-100 text-gray-800' :
          'bg-red-100 text-red-800'
        }`}>
          {event.status}
        </span>
      </div>
    </div>
  );
}

🧪 Tests et qualité

Tests des composants

tsx
// Tests avec React Testing Library
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { EventForm } from '@/components/EventForm';

describe('EventForm', () => {
  it('should render form fields correctly', () => {
    render(<EventForm />);
    
    expect(screen.getByLabelText(/titre/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/date/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/nombre max de joueurs/i)).toBeInTheDocument();
  });

  it('should show validation errors for empty fields', async () => {
    render(<EventForm />);
    
    const submitButton = screen.getByText(/créer l'événement/i);
    fireEvent.click(submitButton);
    
    await waitFor(() => {
      expect(screen.getByText(/le titre est requis/i)).toBeInTheDocument();
      expect(screen.getByText(/la description est requise/i)).toBeInTheDocument();
      expect(screen.getByText(/la date est requise/i)).toBeInTheDocument();
    });
  });

  it('should submit form with valid data', async () => {
    const mockSubmit = jest.fn();
    render(<EventForm onSubmit={mockSubmit} />);
    
    // Remplir le formulaire
    fireEvent.change(screen.getByLabelText(/titre/i), {
      target: { value: 'Match d\'improvisation' }
    });
    
    fireEvent.change(screen.getByLabelText(/description/i), {
      target: { value: 'Un match d\'improvisation passionnant' }
    });
    
    fireEvent.change(screen.getByLabelText(/date/i), {
      target: { value: '2024-12-25T20:00' }
    });
    
    // Soumettre
    const submitButton = screen.getByText(/créer l'événement/i);
    fireEvent.click(submitButton);
    
    await waitFor(() => {
      expect(mockSubmit).toHaveBeenCalledWith({
        title: 'Match d\'improvisation',
        description: 'Un match d\'improvisation passionnant',
        date: '2024-12-25T20:00',
        maxPlayers: 1,
        status: 'draft'
      });
    });
  });
});

🚀 Prochaines étapes


Le frontend LIPAIX évolue constamment. N'hésitez pas à proposer des améliorations ! 🚀

Released under the MIT License.