Skip to content

10. Étude de cas : le client React de l'application RdvMedecins

10.1. Le problème

L'application [RdvMedecins] permet de prendre des rendez-vous dans un cabinet médical. Le cours [NestJS] l'a écrite sous la forme d'une application HTML, dont le serveur fabriquait les pages. Nous en écrivons ici le client [React] : les MÊMES pages, fabriquées dans le navigateur, à partir des données JSON du serveur présenté au chapitre « Le serveur RdvMedecins, une boîte noire ».

Le client doit offrir tout ce qu'offrait l'application HTML :

  • une page de connexion, protégée par un captcha, avec la possibilité de créer un compte de patient ;
  • l'agenda d'un médecin pour un jour donné, avec réservation (fenêtre de choix du patient) et annulation ;
  • pour l'administrateur, la gestion des médecins et des clients (liste, ajout, modification, suppression) ;
  • pour le patient, son espace (son rendez-vous à venir, qu'il peut annuler) et la gestion de son compte ;
  • deux langues, le français et l'anglais ;
  • des adresses propres ([/agenda?idMedecin=1&jour=...]), qu'on peut recharger, mettre en favori, et parcourir avec les boutons Précédent / Suivant du navigateur.

10.2. Installation et exécution

Le serveur JSON doit être installé et lancé (cf. chapitre « Le serveur RdvMedecins, une boîte noire »). Pour utiliser le client dans un navigateur, on n'a PAS besoin du mode test du captcha : on peut laisser [CAPTCHA_TEST] vide dans le fichier [.env] du serveur.

Dans une console ouverte sur le dossier [rdvmedecins-react] :

npm install
npm start

puis on ouvre http://localhost:5173 dans un navigateur. Les comptes de démonstration sont rappelés sur la page de connexion (mot de passe = login).

Les exemples utilisent aussi le port 5173 : on arrête d'abord l'exemple en cours ([Ctrl-C]). On peut aussi lancer le client sur un autre port : [npm start -- --port 5174].

10.3. Utilisation de l'application

10.3.1. La page de connexion

Image

Un mauvais mot de passe : le message s'affiche en bas de la page, et une nouvelle image est proposée (chacune ne sert qu'une fois) :

Image

10.3.2. L'agenda, vu par l'administrateur et les médecins

Connecté comme [admin], on arrive sur l'agenda :

Image

On choisit un médecin et un jour, puis « Voir l'agenda ». L'adresse devient [/agenda?idMedecin=1&jour=2026-10-05] :

Image

« Réserver » ouvre la fenêtre de réservation. L'adresse devient [/agenda?idMedecin=1&jour=2026-10-05&reserver=3] : recharger la page (F5) rouvre la même fenêtre.

Image

Le rendez-vous est enregistré :

Image

« Annuler » demande confirmation :

Image

Un médecin arrive directement sur SON agenda du jour :

Image

10.3.3. L'espace du patient

Un patient ([martin]) arrive sur « Mon espace » :

Image

Dans un agenda, il voit « Réservé » en face des créneaux des autres patients :

Image

Il réserve pour lui-même, sans choisir de patient :

Image

Son rendez-vous apparaît sous son nom ; il n'a plus de bouton « Réserver » (un seul rendez-vous à venir), et un message lui dit pourquoi :

Image

Son espace le montre :

Image

« Gérer mon compte » : toute modification exige le mot de passe actuel ; les erreurs sont celles du serveur :

Image

S'il tape l'adresse /medecins, réservée à l'administrateur :

Image

10.3.4. La gestion des médecins et des clients

Image

Un ajout mal rempli :

Image

Un ajout réussi :

Image

Un homonyme :

Image

La suppression d'un médecin qui a des rendez-vous est refusée :

Image

10.3.5. La création d'un compte

Sur la page de connexion, on recopie le captcha puis on clique sur « Créer un compte client ». Le formulaire, mal rempli, puis refusé par le serveur :

Image

Une fois le compte créé, retour à la page de connexion, login pré-rempli :

Image

10.3.6. En anglais

Les boutons FR / EN retraduisent la page sur place, sans rien demander au serveur ; le titre de l'onglet du navigateur est traduit lui aussi :

Image

10.3.7. Session expirée, adresse inconnue

Si le jeton expire (ou si le cookie disparaît), la requête suivante reçoit 401 ; le client renvoie à la page de connexion :

Image

Une adresse inconnue :

Image

10.4. L'architecture du client

rdvmedecins-react/
├── package.json, vite.config.ts, tsconfig.json
├── index.html                LA page de l'application
├── public/favicon.svg        l'icône de l'onglet
└── src/
    ├── main.tsx              le démarrage
    ├── routeur.ts            le routeur, la session expirée, les messages flash
    ├── routes.tsx            les pages et leurs droits d'accès
    ├── gardes.ts             les gardes du routeur
    ├── styles.css
    ├── formatage.ts          la mise en forme
    ├── api/                  la couche d'accès à l'API
    ├── i18n/                 les langues
    ├── stores/               session, messages, confirmation (Zustand)
    ├── hooks/                l'envoi des formulaires, le captcha
    ├── components/           le gabarit et les composants réutilisables
    └── pages/                les pages

Image

Le tableau suivant met en regard l'application HTML du cours [NestJS] et ce client :

Application HTML (NestJS)
Client React
le serveur fabrique chaque page ([res.render])
le serveur renvoie du JSON ; les composants de [src/pages/] fabriquent les pages
[views/layout.hbs] et ses partiels
[Gabarit.tsx] et les composants de [src/components/]
les routes des contrôleurs ([@Get('agenda')])
les routes du client ([routes.tsx]) ; le serveur n'a que des routes /api/...
[JwtAuthGuard], [RolesGuard] renvoient vers /login ou une page 403
les gardes du routeur ; le serveur garde ses propres gardes, qui répondent 401 / 403
message flash dans un cookie
message flash dans un magasin, [useMessages]
dictionnaires lus par le serveur, helper [{{t}}]
dictionnaires dans le client, [i18next] ; le serveur ne renvoie que des clés
langue mémorisée dans un cookie
langue mémorisée dans le [localStorage]
formulaire refusé : le serveur réaffiche la page
formulaire refusé : réponse 400 / 409, le client affiche les erreurs sous les champs
fenêtre de confirmation : un petit script, [public/js/app.js]
un magasin et un composant, [FenetreConfirmation]
l'application marche sans JavaScript
l'application EST du JavaScript

Les clients [Vue.js] et [Angular] des cours précédents ont exactement la même structure : les « stores » [Pinia] et les services d'[Angular] sont ici des magasins [Zustand], les « composables » des hooks, les vues des pages.

Tous les fichiers du client sont présentés ci-dessous. Ils sont abondamment commentés : leurs commentaires font partie du cours.

10.4.1. La configuration du projet

10.4.1.1. package.json

{
  "name": "rdvmedecins-react",
  "private": true,
  "version": "1.0.0",
  "description": "Client React de l'application de prise de rendez-vous médicaux [RdvMedecins] - client du serveur JSON [rdvmedecins-nestjs-json]",
  "type": "module",
  "scripts": {
    "start": "vite",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@tanstack/react-query": "^5.104.0",
    "bootstrap": "^5.3.8",
    "i18next": "^26.4.2",
    "react": "^19.3.0",
    "react-dom": "^19.3.0",
    "react-i18next": "^17.0.15",
    "react-router": "^8.4.0",
    "zustand": "^5.0.15"
  },
  "devDependencies": {
    "@types/react": "^19.2.18",
    "@types/react-dom": "^19.2.7",
    "@vitejs/plugin-react": "^6.1.1",
    "typescript": "~6.0.2",
    "vite": "^8.3.0"
  }
}
  • lignes 8-10 : [npm start] lance le serveur de développement ; [npm run build] vérifie les types ([tsc --noEmit], cf. plus bas) PUIS construit la version de production ; [npm run preview] sert la version construite, pour l'essayer ;
  • lignes 12-21 : les bibliothèques utilisées par l'application : [React], [React Router], [TanStack Query], [Zustand], [i18next], [Bootstrap] ;
  • lignes 22-28 : les outils de développement : [Vite] et son extension pour [React], [TypeScript], et les types de [React].

Il n'y a pas de bibliothèque de formulaires ([React Hook Form], exemple 08) : comme à l'exemple 25, toutes les règles de saisie sont vérifiées par le serveur, et le client se contente d'afficher ses verdicts.

10.4.1.2. vite.config.ts

// =========================================================================
// vite.config.ts - l'outil de construction du client
// -------------------------------------------------------------------------
//   - npm start      : le serveur de développement (http://localhost:5173),
//                      qui transmet /api/... au serveur JSON (port 8080) ;
//   - npm run build  : l'application compilée, dans dist/ ;
//   - npm run preview: sert dist/ (avec le même proxy), pour l'essayer.
// =========================================================================

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// le proxy : pages et API ont ainsi la MÊME origine (localhost:5173) ; le
// cookie httpOnly du jeton circule sans configuration, et pas de CORS
const proxy = { '/api': 'http://localhost:8080' };

export default defineConfig({
  plugins: [react()],
  server: { port: 5173, proxy },
  preview: { port: 4173, proxy },
  // en production : le code des bibliothèques (React, React Router,
  // i18next...) dans UN fichier à part, "bibliotheques-XXXX.js". Il change
  // rarement : le navigateur le garde en cache d'une version à l'autre.
  build: {
    rolldownOptions: {
      output: { codeSplitting: { groups: [{ name: 'bibliotheques', test: /node_modules/ }] } },
    },
  },
});
  • ligne 15 : le proxy de l'exemple 22 : les requêtes /api/... sont transmises au serveur JSON ;
  • ligne 19 : le serveur de développement ;
  • ligne 20 : le serveur de [npm run preview], avec le même proxy ;
  • lignes 24-28 : en production, le code des bibliothèques est rangé dans un fichier à part (cf. chapitre « Déploiement »).

10.4.1.3. tsconfig.json

{
  "compilerOptions": {
    "target": "ES2023",
    "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "types": ["vite/client"],
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src", "vite.config.ts"]
}
  • ligne 8 : le JSX est compilé en appels à [jsx(...)], importés automatiquement : pas besoin d'[import React] dans chaque fichier ;
  • ligne 9 : le mode strict de [TypeScript] ;
  • ligne 14 : un import qui ne sert qu'aux types s'écrit [import type] : il disparaît à la compilation ;
  • ligne 15 : seule la syntaxe de [TypeScript] qu'il suffit d'EFFACER pour obtenir du [JavaScript] est permise (pas d'[enum], pas de paramètres propriétés) : c'est ce que fait [Vite], qui ne vérifie pas les types ;
  • ligne 16 : l'import des fichiers JSON (les dictionnaires) ;
  • ligne 18 : [TypeScript] ne produit aucun fichier : il VÉRIFIE seulement ([tsc --noEmit]) ; c'est [Vite] qui compile.

10.4.2. Le démarrage de l'application

10.4.2.1. index.html

<!doctype html>
<!--
  index.html - LA page de l'application (Single Page Application).
  Le serveur n'en envoie jamais d'autre : tout le reste est fabriqué dans
  le navigateur par React, dans <div id="root">. lang et title sont mis à
  jour par l'application selon la langue et la page affichée.
-->
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>RdvMedecins</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
</head>
<body>
  <div id="root"></div>
  <!-- sans JavaScript, une application React ne peut rien afficher : on le dit -->
  <noscript>RdvMedecins a besoin de JavaScript. / RdvMedecins requires JavaScript.</noscript>
  <script type="module" src="/src/main.tsx"></script>
</body>
</html>
  • ligne 16 : l'élément où [React] affiche l'application ;
  • ligne 18 : sans [JavaScript], une application [React] ne peut rien afficher : on le dit ;
  • ligne 19 : le point d'entrée de l'application. En développement, [Vite] compile [main.tsx] à la demande ; en production, cette ligne est remplacée par le fichier compilé (cf. chapitre « Déploiement »).

10.4.2.2. src/main.tsx

// =========================================================================
// main.tsx - le démarrage de l'application
// -------------------------------------------------------------------------
//   - les feuilles de style (Bootstrap, styles.css) ;
//   - i18next (français et anglais) ;
//   - TanStack Query, qui lit les données du serveur : ses requêtes en
//     échec affichent leur message dans la zone d'erreur, en UN endroit
//     (sauf celles qui déclarent traiter elles-mêmes leur erreur :
//     meta.erreurGeree) ;
//   - le routeur, qui affiche la page de l'adresse courante.
// =========================================================================

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from 'react-router/dom';
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles.css';
import './i18n/i18n';
import { useMessages } from './stores/messages';
import { routeur } from './routeur';

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (erreur, requete) => {
      if (!requete.meta?.erreurGeree) {
        useMessages.getState().afficherErreurApi(erreur);
      }
    },
  }),
  defaultOptions: {
    queries: {
      retry: false, // une erreur s'affiche tout de suite, sans nouvel essai
      refetchOnWindowFocus: false, // pas de relecture en revenant sur l'onglet
    },
  },
});

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={routeur} />
    </QueryClientProvider>
  </StrictMode>,
);
  • lignes 17-19 : les feuilles de style, puis la configuration d'[i18next], exécutée une fois au chargement du module ;
  • lignes 23-37 : le client de [TanStack Query] (exemple 24), avec deux réglages :
    • lignes 24-30 : [onError] est appelée pour CHAQUE requête en échec : son message s'affiche dans la zone d'erreur, en un seul endroit. Une requête qui traite elle-même son erreur le déclare dans ses métadonnées : [meta: { erreurGeree: true }] ;
    • lignes 33-34 : par défaut, [TanStack Query] réessaie trois fois une requête en échec, et relit les données quand on revient sur l'onglet : ici, une erreur s'affiche tout de suite, et les données ne changent que lorsqu'on le demande ;
  • ligne 42 : le routeur (fabriqué dans [routeur.ts]) affiche la page de l'adresse courante.

10.4.2.3. src/routeur.ts

// =========================================================================
// routeur.ts - le routeur de l'application, et ce qui en dépend
// -------------------------------------------------------------------------
// Le routeur est fabriqué ici, hors de tout composant, pour que deux
// mécanismes puissent s'en servir :
//   1. la réponse 401 d'une requête protégée (session expirée) : on oublie
//      l'utilisateur, on prépare le message, et l'on va à la page de
//      connexion (cf. api/client.ts) ;
//   2. les messages "flash" : à la fin de CHAQUE navigation, les messages
//      de la page précédente disparaissent, ceux déposés pour la page
//      suivante apparaissent (cf. stores/messages.ts).
// =========================================================================

import { createBrowserRouter } from 'react-router';
import { routes } from './routes';
import { quandSessionExpiree } from './api/client';
import { useSession } from './stores/session';
import { useMessages } from './stores/messages';

export const routeur = createBrowserRouter(routes);

// 1. la session expirée
quandSessionExpiree(() => {
  useSession.getState().oublier();
  useMessages.getState().flash('erreur', 'ERRORS.SESSION_EXPIRED');
  routeur.navigate('/login');
});

// 2. subscribe : la fonction est appelée à chaque changement de l'état du
// routeur. Une navigation est TERMINÉE quand elle n'est plus en cours
// (idle) et que l'adresse a changé (chaque adresse visitée a une clé unique,
// location.key - y compris quand seuls les paramètres changent : /agenda?...)
let cleAdresse = routeur.state.location.key;
routeur.subscribe((etat) => {
  if (etat.navigation.state === 'idle' && etat.location.key !== cleAdresse) {
    cleAdresse = etat.location.key;
    useMessages.getState().nouvellePage();
  }
});
  • ligne 20 : le routeur, en mode « données » (exemple 17), fabriqué HORS de tout composant : le code qui n'est pas un composant peut ainsi s'en servir ([routeur.navigate]) ;
  • lignes 23-27 : ce qu'il faut faire quand une requête protégée reçoit 401 (cf. [api/client.ts]) : oublier l'utilisateur, préparer le message de la page suivante, aller à la page de connexion ;
  • lignes 33-39 : [routeur.subscribe(fonction)] : la fonction est appelée à chaque changement de l'état du routeur. À la fin de chaque navigation, les messages de la page précédente disparaissent et les messages « flash » apparaissent (cf. [stores/messages.ts]).

10.4.2.4. src/styles.css

/* =========================================================================
   styles.css - quelques réglages en plus de Bootstrap
   (repris de public/css/styles.css de l'application HTML)
   ========================================================================= */

body {
  background-color: #f5f7fa;
}

.creneau-libre:hover {
  background-color: #e9f7ef;
}

/* -------------------------------------------------------------------------
   .zone-erreur : les messages d'erreur, en bas de page, sur fond rose léger
   (cf. components/ZoneErreur.tsx).
   position: sticky + bottom : si la page est plus haute que la fenêtre, la
   zone reste collée au bas de la fenêtre au lieu de disparaître sous la
   ligne de flottaison - sans jamais recouvrir le contenu quand la page est
   courte. z-index : au-dessus du fond grisé des fenêtres modales.
   pointer-events: none : la zone ne contient rien de cliquable ; si elle
   chevauche un bouton, le clic passe au travers.
   ------------------------------------------------------------------------- */
.zone-erreur {
  position: sticky;
  bottom: 1rem;
  z-index: 1060;
  pointer-events: none;
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef; /* rose léger */
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

/* les deux grandes options de la page "Mon espace" du patient */
.carte-option {
  transition: border-color 0.15s, box-shadow 0.15s;
}

.carte-option:hover {
  border-color: #0d6efd;
  box-shadow: 0 0.25rem 0.75rem rgba(13, 110, 253, 0.15);
}

/* l'image du captcha (SVG de 150 x 50 pixels), insérée par dangerouslySetInnerHTML :
   un style de la feuille globale, comme tous les autres */
.captcha-image svg {
  display: block;
  border: 1px solid #dee2e6;
  border-radius: 0.375rem;
}

La feuille de style de l'application HTML, reprise presque telle quelle. Le seul point à noter est la zone d'erreur (lignes 24-35), collée au bas de la fenêtre quand la page est haute.

10.4.3. La couche d'accès à l'API

10.4.3.1. src/api/types.ts

// =========================================================================
// api/types.ts - les objets JSON échangés avec le serveur
// -------------------------------------------------------------------------
// La traduction, en interfaces TypeScript, de l'API décrite au chapitre
// "Le serveur, une boîte noire". Le compilateur vérifie ainsi que le client
// n'utilise que des champs qui existent vraiment.
// =========================================================================

export type Role = 'ADMIN' | 'DOCTOR' | 'USER';

// POST /api/auth/login, GET /api/auth/moi, PUT /api/mon-compte
export interface Utilisateur {
  login: string;
  nom: string; // nom affiché : "Marie PELISSIER"
  role: Role;
}

// GET /api/captcha
export interface Captcha {
  id: string;
  svg: string;
}

// un titre, un prénom, un nom
export interface Identite {
  titre: string;
  prenom: string;
  nom: string;
}

// un médecin ou un client : GET /api/medecins, GET /api/clients/:id...
export interface Personne extends Identite {
  id: number;
  version: number; // verrou optimiste : à renvoyer avec une modification
  login?: string; // seulement pour l'ADMIN
}

// ce qu'on envoie pour créer / modifier une personne (ADMIN)
export interface DonneesPersonne extends Identite {
  login: string; // '' = valeur par défaut (création) ou inchangé (modification)
  password: string; // idem
  version?: number;
}

export interface Creneau {
  id: number;
  hdebut: number;
  mdebut: number;
  hfin: number;
  mfin: number;
}

// une ligne de l'agenda, avec les DROITS de l'utilisateur connecté
export interface CreneauAgenda extends Creneau {
  libre: boolean;
  patient: Identite | null; // null : libre, ou occupé par un autre patient
  moi: boolean; // le rendez-vous du patient connecté
  reservable: boolean;
  idRv: number | null; // renseigné si l'utilisateur peut annuler
}

// GET /api/agenda?idMedecin=...&jour=...
export interface Agenda {
  medecin: Personne;
  jour: string; // "yyyy-MM-dd"
  creneaux: CreneauAgenda[];
  info: string | null; // clé de traduction (patient qui a déjà un rendez-vous)
}

// GET /api/mon-espace
export interface MonEspace {
  patient: Identite;
  rendezVous: { id: number; jour: string; creneau: Creneau; medecin: Personne }[];
}

// GET /api/mon-compte
export interface MonCompte extends Identite {
  login: string;
  version: number;
}

// PUT /api/mon-compte
export interface ModificationMonCompte extends Identite {
  login: string;
  password: string; // '' = inchangé
  confirmation: string;
  motDePasseActuel: string;
  version: number;
}

// POST /api/inscription
export interface Inscription extends Identite {
  login: string;
  password: string;
  confirmation: string;
}

Les objets JSON de l'API, décrits au chapitre « Le serveur RdvMedecins, une boîte noire », traduits en interfaces [TypeScript] :

  • lignes 25-29 : un titre, un prénom, un nom : ce qu'ont en commun les médecins, les clients, les patients des rendez-vous ;
  • lignes 32-36 : un médecin ou un client. [version] : le verrou optimiste du serveur ; une modification renvoie la version lue : si quelqu'un a modifié la fiche entre-temps, le serveur refuse ([ERRORS.CONCURRENT_UPDATE]) ;
  • lignes 54-60 : une ligne de l'agenda. Le serveur y indique les DROITS de l'utilisateur connecté : [reservable], [idRv] (renseigné s'il peut annuler) ; le client n'a pas à les recalculer ;
  • ligne 67 : une clé de traduction, pour le patient qui a déjà un rendez-vous.

10.4.3.2. src/api/erreur-api.ts

// =========================================================================
// api/erreur-api.ts - UNE classe pour toutes les erreurs de l'API
// -------------------------------------------------------------------------
// Reprise de commun/erreur-api.ts des exemples. Toute erreur d'une requête
// (réponse 4xx / 5xx du serveur, serveur injoignable, réponse d'un proxy)
// devient une ErreurApi, qui porte la CLÉ de traduction du message, ses
// paramètres, et l'erreur de chaque champ d'un formulaire refusé.
// =========================================================================

export class ErreurApi extends Error {
  readonly statusCode: number; // 0 : le serveur n'a pas répondu
  readonly cle: string; // clé de traduction : 'ERRORS.LOGIN_TAKEN'
  readonly params: Record<string, string>;
  readonly champs: Record<string, string>; // erreur de chaque champ

  constructor(statusCode: number, cle: string, params: Record<string, string> = {}, champs: Record<string, string> = {}) {
    super(cle);
    this.name = 'ErreurApi';
    this.statusCode = statusCode;
    this.cle = cle;
    this.params = params;
    this.champs = champs;
  }
}

// une réponse d'erreur (4xx, 5xx) -> une ErreurApi
export async function depuisReponse(reponse: Response): Promise<ErreurApi> {
  let corps: { cle?: string; params?: Record<string, string>; champs?: Record<string, string> } = {};
  try {
    const json: unknown = await reponse.json();
    if (typeof json === 'object' && json !== null) {
      corps = json;
    }
  } catch {
    // pas du JSON : ce n'est pas notre serveur qui a répondu
  }
  return new ErreurApi(
    reponse.status,
    // pas de clé : ce n'est pas notre serveur qui a répondu, mais un proxy
    // (Vite : 500 ou 502 sans corps JSON ; nginx : 502, 504...)
    corps.cle ?? (reponse.status >= 500 ? 'ERRORS.SERVER_UNREACHABLE' : 'ERRORS.SERVER_ERROR'),
    corps.params,
    corps.champs,
  );
}

La classe [ErreurApi] de l'exemple 24, sans changement : TOUTE erreur d'une requête devient une [ErreurApi], qui porte la clé de traduction du message, ses paramètres, et l'erreur de chaque champ d'un formulaire refusé.

10.4.3.3. src/api/client.ts

// =========================================================================
// api/client.ts - TOUTES les requêtes vers l'API passent par ici
// -------------------------------------------------------------------------
// Repris de commun/api.ts des exemples, avec une nouveauté : la gestion
// centralisée de la réponse 401.
//
// Un 401 peut arriver n'importe quand : le jeton a expiré (2 heures), le
// compte a été supprimé, le serveur a redémarré avec une autre clé
// secrète... Plutôt que de traiter ce cas dans chaque composant, on le
// traite ICI, une fois. Mais ce fichier ne connaît ni le routeur ni les
// messages (il ne dépend de rien) : il appelle une fonction qu'on lui a
// confiée au démarrage (quandSessionExpiree, cf. routeur.ts), qui oublie
// l'utilisateur et renvoie vers la page de connexion.
//
// Certaines requêtes traitent elles-mêmes leur 401 (la connexion, GET
// /api/auth/moi...) : elles sont "publiques" (cf. rdvmedecins.api.ts).
// =========================================================================

import { depuisReponse, ErreurApi } from './erreur-api';

// ce qu'il faut faire quand le serveur répond 401 à une requête protégée
let sessionExpiree: () => void = () => {};

export function quandSessionExpiree(action: () => void): void {
  sessionExpiree = action;
}

export type Methode = 'GET' | 'POST' | 'PUT' | 'DELETE';

// publique : true pour les routes qui traitent elles-mêmes le 401
export async function requete<T>(methode: Methode, url: string, corps?: unknown, publique = false): Promise<T> {
  let reponse: Response;
  try {
    reponse = await fetch(`/api${url}`, {
      method: methode,
      headers: corps === undefined ? {} : { 'Content-Type': 'application/json' },
      body: corps === undefined ? undefined : JSON.stringify(corps),
    });
  } catch {
    // aucune réponse : réseau coupé, serveur arrêté
    throw new ErreurApi(0, 'ERRORS.SERVER_UNREACHABLE');
  }
  if (!reponse.ok) {
    const erreur = await depuisReponse(reponse);
    if (erreur.statusCode === 401 && !publique) {
      sessionExpiree();
    }
    throw erreur;
  }
  return (await reponse.json()) as T;
}

La fonction [requete] de l'exemple 24, avec une nouveauté : la réponse 401.

  • lignes 22-26 : la fonction à appeler quand une requête protégée reçoit 401. Ce fichier ne dépend de rien (ni du routeur, ni des messages) : c'est [routeur.ts] qui lui confie cette fonction au démarrage. Une dépendance circulaire (le client importe le routeur, qui importe les routes, qui importent les pages, qui importent le client...) est ainsi évitée ;
  • ligne 31 : certaines requêtes traitent elles-mêmes leur 401 : la connexion (mauvais mot de passe : 401), [GET /api/auth/moi] (personne n'est connecté : 401)... ;
  • lignes 45-47 : une requête protégée reçoit 401 : la session a expiré. On le signale, PUIS on rejette la promesse comme pour toute autre erreur : le composant qui attendait la réponse n'a rien de particulier à faire.

10.4.3.4. src/api/rdvmedecins.api.ts

// =========================================================================
// api/rdvmedecins.api.ts - une fonction par route de l'API
// -------------------------------------------------------------------------
// La "couche d'accès aux données" du client : les composants appellent ces
// fonctions, typées, sans rien savoir des URL, de fetch ni du JSON. Si
// l'API change, seul ce fichier change.
// Chaque fonction renvoie une PROMESSE : les composants enchaînent les
// requêtes avec async / await. En cas d'échec, la promesse est rejetée
// avec une ErreurApi (cf. client.ts).
// =========================================================================

import { requete } from './client';
import type {
  Agenda,
  Captcha,
  DonneesPersonne,
  Inscription,
  ModificationMonCompte,
  MonCompte,
  MonEspace,
  Personne,
  Utilisateur,
} from './types';

// /api/medecins ou /api/clients : les deux se gèrent de la même façon
export type TypePersonnes = 'medecins' | 'clients';

type Vide = Record<string, never>;

export const api = {
  // ------------------------------------------------ connexion (routes publiques)
  captcha: () => requete<Captcha>('GET', '/captcha', undefined, true),
  moi: () => requete<Utilisateur>('GET', '/auth/moi', undefined, true),
  connecter: (login: string, password: string, captchaId: string, captcha: string) =>
    requete<Utilisateur>('POST', '/auth/login', { login, password, captchaId, captcha }, true),
  deconnecter: () => requete<Vide>('POST', '/auth/logout', {}, true),

  // ------------------------------------------------ création de compte (publique)
  accesInscription: (captchaId: string, captcha: string) =>
    requete<Vide>('POST', '/inscription/acces', { captchaId, captcha }, true),
  etatAccesInscription: () => requete<{ valide: boolean }>('GET', '/inscription/acces', undefined, true),
  inscrire: (donnees: Inscription) => requete<{ login: string }>('POST', '/inscription', donnees, true),

  // ------------------------------------------------ agenda et rendez-vous
  // idMedecin null : pour un médecin connecté, SON agenda
  agenda(idMedecin: number | null, jour: string) {
    const parametres = new URLSearchParams({ jour });
    if (idMedecin !== null) {
      parametres.set('idMedecin', String(idMedecin));
    }
    return requete<Agenda>('GET', `/agenda?${parametres}`);
  },
  reserver: (jour: string, idCreneau: number, idClient: number | null) =>
    requete<{ id: number }>('POST', '/rendez-vous', { jour, idCreneau, idClient: idClient ?? undefined }),
  annulerRv: (idRv: number) => requete<Vide>('DELETE', `/rendez-vous/${idRv}`),

  // ------------------------------------------------ médecins et clients
  lister: (type: TypePersonnes) => requete<Personne[]>('GET', `/${type}`),
  lire: (type: TypePersonnes, id: number) => requete<Personne>('GET', `/${type}/${id}`),
  ajouter: (type: TypePersonnes, donnees: DonneesPersonne) => requete<Personne>('POST', `/${type}`, donnees),
  modifier: (type: TypePersonnes, id: number, donnees: DonneesPersonne) => requete<Personne>('PUT', `/${type}/${id}`, donnees),
  supprimer: (type: TypePersonnes, id: number) => requete<Vide>('DELETE', `/${type}/${id}`),

  // ------------------------------------------------ l'espace du patient
  monEspace: () => requete<MonEspace>('GET', '/mon-espace'),
  monCompte: () => requete<MonCompte>('GET', '/mon-compte'),
  modifierMonCompte: (donnees: ModificationMonCompte) => requete<Utilisateur>('PUT', '/mon-compte', donnees),
  supprimerMonCompte: (motDePasseActuel: string) => requete<Vide>('DELETE', '/mon-compte', { motDePasseActuel }),
};

Une fonction par route de l'API, typée : les composants appellent [api.agenda(1, '2026-10-05')] sans rien savoir des URL ni du JSON. Si l'API change, seul ce fichier change.

  • lignes 32-36 : les routes de la connexion, publiques (dernier paramètre de [requete] : [true]) ;
  • lignes 39-42 : la création d'un compte, en deux temps : le captcha résolu sur la page de connexion donne un « laissez-passer » (un cookie), que la route [POST /api/inscription] exige ;
  • lignes 46-52 : [URLSearchParams] fabrique les paramètres de l'adresse (et les encode). Pour un médecin connecté, pas d'[idMedecin] : le serveur renvoie SON agenda ;
  • ligne 54 : un patient réserve pour lui-même : pas d'[idClient]. [JSON.stringify] omet les propriétés [undefined] ;
  • lignes 58-62 : médecins et clients se gèrent de la même façon : le type est un paramètre ;
  • ligne 28 : le type d'un objet vide [{}], que renvoient certaines routes.

10.4.4. Les langues

10.4.4.1. src/i18n/i18n.ts

// =========================================================================
// i18n/i18n.ts - le français et l'anglais (i18next, cf. exemple 21)
// -------------------------------------------------------------------------
// Les dictionnaires fr.json et en.json sont ceux du cours NestJS
// (i18n/*.json), complétés de quelques clés propres au client
// (ERRORS.SERVER_UNREACHABLE...). Le serveur JSON ne renvoie que des CLÉS
// ('ERRORS.LOGIN_TAKEN') : ce sont les mêmes que celles des dictionnaires.
//
// La langue choisie est mémorisée dans localStorage (une préférence, pas
// une donnée sensible) ; à défaut, on prend celle du navigateur.
// =========================================================================

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import fr from './fr.json';
import en from './en.json';

export type Langue = 'fr' | 'en';
export const LANGUES: Langue[] = ['fr', 'en'];
const CLE_STOCKAGE = 'rdvmedecins.langue';

function langueDeDepart(): Langue {
  try {
    const memorisee = localStorage.getItem(CLE_STOCKAGE);
    if (memorisee === 'fr' || memorisee === 'en') {
      return memorisee;
    }
  } catch {
    // localStorage indisponible (navigation privée stricte...) : tant pis
  }
  return navigator.language.toLowerCase().startsWith('en') ? 'en' : 'fr';
}

// à chaque changement de langue : la mémoriser, et mettre à jour
// l'attribut lang de la page (lecteurs d'écran, correcteurs, césure)
i18n.on('languageChanged', (langue) => {
  document.documentElement.lang = langue;
  try {
    localStorage.setItem(CLE_STOCKAGE, langue);
  } catch {
    // sans importance
  }
});

i18n.use(initReactI18next).init({
  // les dictionnaires font partie du code de l'application : pas de requête
  resources: { fr: { translation: fr }, en: { translation: en } },
  lng: langueDeDepart(),
  fallbackLng: 'fr',
  interpolation: { escapeValue: false }, // React échappe déjà tout
});

export default i18n;

La configuration de l'exemple 21 :

  • lignes 22-32 : la langue mémorisée, sinon celle du navigateur. L'accès à [localStorage] peut échouer (navigation privée stricte) : [try / catch] ;
  • lignes 36-43 : à chaque changement de langue, on la mémorise et on met à jour l'attribut [lang] de la page ;
  • ligne 47 : les dictionnaires, importés comme des modules : ils font partie du code compilé, sans requête supplémentaire ;
  • ligne 50 : [React] échappe déjà tout ce qu'il affiche : inutile de le faire deux fois.

Il n'y a pas d'équivalent de la « stratégie de titre » d'[Angular] : chaque page déclare son titre avec la balise [<title>], que [React] 19 place dans l'en-tête du document (cf. [components/TitrePage.tsx]).

10.4.4.2. Les dictionnaires

Les dictionnaires [fr.json] et [en.json] sont ceux de l'application HTML du cours [NestJS], complétés de quelques clés propres au client ([ERRORS.SERVER_UNREACHABLE]...). Leur syntaxe des paramètres, [{{min}}], est justement celle d'[i18next]. Le serveur JSON renvoie les mêmes clés.

{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Se déconnecter",
    "MENU_AGENDA": "Agenda",
    "MENU_DOCTORS": "Médecins",
    "MENU_PATIENTS": "Clients",
    "MENU_MY_SPACE": "Mon espace",
    "LOADING": "Chargement...",
    "LANGUAGE": "Langue"
  },
  "LOGIN": {
    "TITLE": "Connexion",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Mot de passe",
    "SUBMIT": "Se connecter",
    "ERROR": "Login ou mot de passe incorrect.",
    "DEMO_ACCOUNTS": "Comptes de démonstration (mot de passe = login) :",
    "DEMO_ADMIN": "rôle ADMIN : rendez-vous + gestion des médecins et des clients",
    "DEMO_DOCTOR": "rôle DOCTOR : prise et annulation de rendez-vous",
    "DEMO_USER": "rôle USER (patients) : réservation pour eux-mêmes, gestion de leur compte",
    "CREATE_ACCOUNT": "Créer un compte client",
    "CREATE_ACCOUNT_HELP": "Pour créer un compte, recopiez d'abord les caractères de l'image."
  },
  "DOCTOR_DAY_PICKER": {
    "DOCTOR_LABEL": "Médecin",
    "CHOOSE_DOCTOR": "-- choisir un médecin --",
    "DAY_LABEL": "Jour",
    "VIEW_AGENDA": "Voir l'agenda"
  },
  "AGENDA": {
    "PAGE_TITLE": "Agenda",
    "TITLE_PREFIX": "Agenda du",
    "COLUMN_SLOT": "Créneau",
    "COLUMN_STATUS": "Statut",
    "FREE": "Libre",
    "BOOK": "Réserver",
    "CANCEL_APPOINTMENT": "Annuler",
    "CANCEL_CONFIRM": "Annuler ce rendez-vous ?",
    "EMPTY_STATE": "Choisissez un médecin et un jour, puis cliquez sur « Voir l'agenda ».",
    "NO_SLOTS": "Ce médecin n'a aucun créneau horaire.",
    "BOOKED": "Rendez-vous enregistré.",
    "CANCELLED": "Rendez-vous annulé.",
    "BOOKED_BY_OTHER": "Réservé",
    "ALREADY_BOOKED_INFO": "Vous avez déjà un rendez-vous à venir : annulez-le pour en prendre un autre."
  },
  "BOOKING_DIALOG": {
    "TITLE": "Réserver ce créneau",
    "CLOSE_ARIA": "Fermer",
    "SLOT": "Créneau de {{horaire}}",
    "PATIENT_LABEL": "Patient",
    "CHOOSE_PATIENT": "-- choisir un patient --",
    "CANCEL": "Annuler",
    "CONFIRM": "Confirmer"
  },
  "PERSONNES": {
    "COLUMN_TITLE": "Titre",
    "COLUMN_FIRSTNAME": "Prénom",
    "COLUMN_LASTNAME": "Nom",
    "COLUMN_LOGIN": "Login",
    "EDIT": "Modifier",
    "DELETE": "Supprimer",
    "SAVE": "Enregistrer",
    "BACK": "Retour à la liste",
    "TITLE_LABEL": "Titre",
    "FIRSTNAME_LABEL": "Prénom",
    "LASTNAME_LABEL": "Nom",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Mot de passe",
    "LOGIN_HELP_CREATE": "Facultatif : par défaut, le nom en minuscules.",
    "LOGIN_HELP_EDIT": "Laisser vide pour ne pas le changer.",
    "PASSWORD_HELP_CREATE": "Facultatif : par défaut, identique au login.",
    "PASSWORD_HELP_EDIT": "Laisser vide pour ne pas le changer.",
    "ACCOUNT_SECTION": "Compte utilisateur"
  },
  "MEDECINS": {
    "TITLE": "Médecins",
    "ADD": "Ajouter un médecin",
    "EDIT": "Modifier un médecin",
    "EMPTY": "Aucun médecin.",
    "DELETE_CONFIRM": "Supprimer ce médecin, ses créneaux horaires et son compte utilisateur ?",
    "ADDED": "Le médecin {{nom}} a été ajouté (login : {{login}}).",
    "UPDATED": "Le médecin {{nom}} a été modifié.",
    "DELETED": "Le médecin {{nom}} a été supprimé."
  },
  "CLIENTS": {
    "TITLE": "Clients",
    "ADD": "Ajouter un client",
    "EDIT": "Modifier un client",
    "EMPTY": "Aucun client.",
    "DELETE_CONFIRM": "Supprimer ce client, son compte utilisateur et tous ses rendez-vous ?",
    "ADDED": "Le client {{nom}} a été ajouté (login : {{login}}).",
    "UPDATED": "Le client {{nom}} a été modifié.",
    "DELETED": "Le client {{nom}} a été supprimé."
  },
  "ERROR_PAGE": {
    "TITLE": "Erreur",
    "BACK": "Retour à l'accueil"
  },
  "ERRORS": {
    "LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
    "DOCTOR_ALREADY_EXISTS": "Un médecin portant ce prénom et ce nom existe déjà.",
    "PATIENT_ALREADY_EXISTS": "Un client portant ce prénom et ce nom existe déjà.",
    "DOCTOR_HAS_APPOINTMENTS": "Ce médecin a des rendez-vous : annulez-les avant de le supprimer.",
    "SLOT_ALREADY_BOOKED": "Ce créneau vient d'être réservé par quelqu'un d'autre.",
    "CONCURRENT_UPDATE": "Cette fiche a été modifiée entre-temps par un autre utilisateur. Revenez à la liste et rouvrez-la avant de recommencer.",
    "DOCTOR_NOT_FOUND": "Ce médecin n'existe pas (ou plus).",
    "PATIENT_NOT_FOUND": "Ce client n'existe pas (ou plus).",
    "SLOT_NOT_FOUND": "Ce créneau n'existe pas (ou plus).",
    "APPOINTMENT_NOT_FOUND": "Ce rendez-vous n'existe pas (ou plus).",
    "INVALID_DATE": "La date demandée n'est pas valide.",
    "CHOOSE_PATIENT": "Choisissez un patient.",
    "INVALID_FORM": "Le formulaire contient des erreurs.",
    "INVALID_TITLE": "Choisissez un titre dans la liste.",
    "FIRSTNAME_REQUIRED": "Le prénom est obligatoire.",
    "FIRSTNAME_TOO_LONG": "Le prénom ne doit pas dépasser 20 caractères.",
    "LASTNAME_REQUIRED": "Le nom est obligatoire.",
    "LASTNAME_TOO_LONG": "Le nom ne doit pas dépasser 20 caractères.",
    "INVALID_LOGIN": "30 caractères au plus : lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
    "PASSWORD_TOO_LONG": "Le mot de passe ne doit pas dépasser 72 caractères.",
    "SESSION_EXPIRED": "Votre session a expiré : reconnectez-vous.",
    "BAD_REQUEST": "La requête est invalide.",
    "FORBIDDEN": "Votre rôle ne vous permet pas d'accéder à cette page.",
    "NOT_FOUND": "Cette page n'existe pas.",
    "SERVER_ERROR": "Une erreur inattendue s'est produite. Réessayez plus tard.",
    "TOO_MANY_ATTEMPTS": "Trop de tentatives infructueuses. Réessayez dans {{minutes}} minute(s).",
    "WRONG_CAPTCHA": "Les caractères recopiés ne correspondent pas à l'image. Une nouvelle image vous est proposée.",
    "TOO_MANY_ACCOUNTS": "Trop de comptes ont été créés depuis votre connexion. Réessayez dans {{minutes}} minute(s).",
    "LOGIN_REQUIRED": "Le login est obligatoire.",
    "PASSWORD_REQUIRED": "Le mot de passe est obligatoire.",
    "PASSWORD_TOO_SHORT": "Le mot de passe doit comporter au moins {{min}} caractères.",
    "CONFIRMATION_REQUIRED": "Confirmez le mot de passe.",
    "PASSWORDS_DIFFER": "La confirmation ne correspond pas au mot de passe.",
    "CURRENT_PASSWORD_REQUIRED": "Saisissez votre mot de passe actuel.",
    "WRONG_CURRENT_PASSWORD": "Mot de passe actuel incorrect.",
    "PAST_DATE": "On ne peut pas prendre de rendez-vous à une date passée.",
    "PATIENT_HAS_UPCOMING_APPOINTMENT": "Ce patient a déjà un rendez-vous à venir : il faut l'annuler avant d'en prendre un autre.",
    "CAPTCHA_BEFORE_ACCOUNT": "Pour créer un compte, recopiez d'abord correctement les caractères de l'image, puis cliquez sur « Créer un compte client ».",
    "AUTH_REQUIRED": "Connectez-vous pour accéder à cette page.",
    "JSON_REQUIRED": "Requête refusée : le serveur attend du JSON.",
    "SERVER_UNREACHABLE": "Le serveur ne répond pas. Réessayez plus tard."
  },
  "CAPTCHA": {
    "LABEL": "Recopiez les caractères de l'image",
    "IMAGE_ALT": "Image de vérification anti-robots",
    "NEW_IMAGE": "Autre image",
    "HELP": "Majuscules ou minuscules, peu importe."
  },
  "INSCRIPTION": {
    "TITLE": "Créer un compte client",
    "INTRO": "Ce compte vous permettra de prendre vous-même vos rendez-vous.",
    "LOGIN_HELP": "Lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
    "PASSWORD_HELP": "Au moins {{min}} caractères.",
    "SUBMIT": "Créer mon compte",
    "BACK_TO_LOGIN": "Retour à la connexion",
    "DONE": "Votre compte a été créé. Vous pouvez vous connecter avec le login {{login}}."
  },
  "COMPTE": {
    "CONFIRMATION_LABEL": "Confirmation du mot de passe"
  },
  "ESPACE": {
    "TITLE": "Mon espace",
    "HELLO": "Bonjour {{nom}}",
    "MANAGE_ACCOUNT": "Gérer mon compte",
    "MANAGE_ACCOUNT_HELP": "Modifier mes informations, mon mot de passe, ou supprimer mon compte.",
    "VIEW_AGENDA": "Voir un agenda",
    "VIEW_AGENDA_HELP": "Consulter l'agenda d'un médecin et réserver un créneau.",
    "MY_APPOINTMENT": "Mon rendez-vous",
    "SEE_IN_AGENDA": "voir dans l'agenda",
    "CANCEL_APPOINTMENT": "Annuler ce rendez-vous",
    "NO_APPOINTMENT": "Vous n'avez aucun rendez-vous à venir."
  },
  "MON_COMPTE": {
    "TITLE": "Gérer mon compte",
    "NEW_PASSWORD_LABEL": "Nouveau mot de passe",
    "CURRENT_PASSWORD_LABEL": "Mot de passe actuel",
    "CURRENT_PASSWORD_HELP": "Obligatoire pour enregistrer une modification.",
    "BACK": "Retour à mon espace",
    "DELETE_TITLE": "Supprimer mon compte",
    "DELETE_HELP": "Votre compte sera définitivement supprimé, ainsi que tous vos rendez-vous.",
    "DELETE_CONFIRM": "Supprimer définitivement votre compte ? Votre rendez-vous éventuel sera annulé.",
    "DELETE_SUBMIT": "Supprimer mon compte",
    "UPDATED": "Votre compte a été modifié.",
    "DELETED": "Votre compte a été supprimé."
  },
  "CONFIRMATION": {
    "TITLE": "Confirmation",
    "NO": "Non, revenir",
    "YES": "Oui, confirmer"
  }
}
{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Log out",
    "MENU_AGENDA": "Schedule",
    "MENU_DOCTORS": "Doctors",
    "MENU_PATIENTS": "Patients",
    "MENU_MY_SPACE": "My space",
    "LOADING": "Loading...",
    "LANGUAGE": "Language"
  },
  "LOGIN": {
    "TITLE": "Login",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Password",
    "SUBMIT": "Log in",
    "ERROR": "Incorrect login or password.",
    "DEMO_ACCOUNTS": "Demo accounts (password = login):",
    "DEMO_ADMIN": "ADMIN role: appointments + doctor and patient management",
    "DEMO_DOCTOR": "DOCTOR role: booking and cancelling appointments",
    "DEMO_USER": "USER role (patients): booking for themselves, managing their account",
    "CREATE_ACCOUNT": "Create a patient account",
    "CREATE_ACCOUNT_HELP": "To create an account, first type the characters shown in the image."
  },
  "DOCTOR_DAY_PICKER": {
    "DOCTOR_LABEL": "Doctor",
    "CHOOSE_DOCTOR": "-- choose a doctor --",
    "DAY_LABEL": "Day",
    "VIEW_AGENDA": "View schedule"
  },
  "AGENDA": {
    "PAGE_TITLE": "Schedule",
    "TITLE_PREFIX": "Schedule for",
    "COLUMN_SLOT": "Slot",
    "COLUMN_STATUS": "Status",
    "FREE": "Free",
    "BOOK": "Book",
    "CANCEL_APPOINTMENT": "Cancel",
    "CANCEL_CONFIRM": "Cancel this appointment?",
    "EMPTY_STATE": "Choose a doctor and a day, then click \"View schedule\".",
    "NO_SLOTS": "This doctor has no time slots.",
    "BOOKED": "Appointment booked.",
    "CANCELLED": "Appointment cancelled.",
    "BOOKED_BY_OTHER": "Booked",
    "ALREADY_BOOKED_INFO": "You already have an upcoming appointment: cancel it to book another one."
  },
  "BOOKING_DIALOG": {
    "TITLE": "Book this slot",
    "CLOSE_ARIA": "Close",
    "SLOT": "Slot {{horaire}}",
    "PATIENT_LABEL": "Patient",
    "CHOOSE_PATIENT": "-- choose a patient --",
    "CANCEL": "Cancel",
    "CONFIRM": "Confirm"
  },
  "PERSONNES": {
    "COLUMN_TITLE": "Title",
    "COLUMN_FIRSTNAME": "First name",
    "COLUMN_LASTNAME": "Last name",
    "COLUMN_LOGIN": "Login",
    "EDIT": "Edit",
    "DELETE": "Delete",
    "SAVE": "Save",
    "BACK": "Back to the list",
    "TITLE_LABEL": "Title",
    "FIRSTNAME_LABEL": "First name",
    "LASTNAME_LABEL": "Last name",
    "LOGIN_LABEL": "Login",
    "PASSWORD_LABEL": "Password",
    "LOGIN_HELP_CREATE": "Optional: defaults to the last name in lower case.",
    "LOGIN_HELP_EDIT": "Leave empty to keep it unchanged.",
    "PASSWORD_HELP_CREATE": "Optional: defaults to the login.",
    "PASSWORD_HELP_EDIT": "Leave empty to keep it unchanged.",
    "ACCOUNT_SECTION": "User account"
  },
  "MEDECINS": {
    "TITLE": "Doctors",
    "ADD": "Add a doctor",
    "EDIT": "Edit a doctor",
    "EMPTY": "No doctors.",
    "DELETE_CONFIRM": "Delete this doctor, their time slots and their user account?",
    "ADDED": "Doctor {{nom}} has been added (login: {{login}}).",
    "UPDATED": "Doctor {{nom}} has been updated.",
    "DELETED": "Doctor {{nom}} has been deleted."
  },
  "CLIENTS": {
    "TITLE": "Patients",
    "ADD": "Add a patient",
    "EDIT": "Edit a patient",
    "EMPTY": "No patients.",
    "DELETE_CONFIRM": "Delete this patient, their user account and all their appointments?",
    "ADDED": "Patient {{nom}} has been added (login: {{login}}).",
    "UPDATED": "Patient {{nom}} has been updated.",
    "DELETED": "Patient {{nom}} has been deleted."
  },
  "ERROR_PAGE": {
    "TITLE": "Error",
    "BACK": "Back to the home page"
  },
  "ERRORS": {
    "LOGIN_TAKEN": "This login is already used by another account.",
    "DOCTOR_ALREADY_EXISTS": "A doctor with this first and last name already exists.",
    "PATIENT_ALREADY_EXISTS": "A patient with this first and last name already exists.",
    "DOCTOR_HAS_APPOINTMENTS": "This doctor has appointments: cancel them before deleting the doctor.",
    "SLOT_ALREADY_BOOKED": "This slot has just been booked by someone else.",
    "CONCURRENT_UPDATE": "This record was changed by another user in the meantime. Go back to the list and reopen it before trying again.",
    "DOCTOR_NOT_FOUND": "This doctor does not exist (any more).",
    "PATIENT_NOT_FOUND": "This patient does not exist (any more).",
    "SLOT_NOT_FOUND": "This slot does not exist (any more).",
    "APPOINTMENT_NOT_FOUND": "This appointment does not exist (any more).",
    "INVALID_DATE": "The requested date is not valid.",
    "CHOOSE_PATIENT": "Choose a patient.",
    "INVALID_FORM": "The form contains errors.",
    "INVALID_TITLE": "Choose a title from the list.",
    "FIRSTNAME_REQUIRED": "The first name is required.",
    "FIRSTNAME_TOO_LONG": "The first name must not exceed 20 characters.",
    "LASTNAME_REQUIRED": "The last name is required.",
    "LASTNAME_TOO_LONG": "The last name must not exceed 20 characters.",
    "INVALID_LOGIN": "At most 30 characters: unaccented lower-case letters, digits, dot, hyphen, underscore.",
    "PASSWORD_TOO_LONG": "The password must not exceed 72 characters.",
    "SESSION_EXPIRED": "Your session has expired: please log in again.",
    "BAD_REQUEST": "The request is invalid.",
    "FORBIDDEN": "Your role does not allow you to access this page.",
    "NOT_FOUND": "This page does not exist.",
    "SERVER_ERROR": "An unexpected error occurred. Please try again later.",
    "TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {{minutes}} minute(s).",
    "WRONG_CAPTCHA": "The characters you typed do not match the image. A new image is shown.",
    "TOO_MANY_ACCOUNTS": "Too many accounts have been created from your connection. Try again in {{minutes}} minute(s).",
    "LOGIN_REQUIRED": "The login is required.",
    "PASSWORD_REQUIRED": "The password is required.",
    "PASSWORD_TOO_SHORT": "The password must be at least {{min}} characters long.",
    "CONFIRMATION_REQUIRED": "Please confirm the password.",
    "PASSWORDS_DIFFER": "The confirmation does not match the password.",
    "CURRENT_PASSWORD_REQUIRED": "Enter your current password.",
    "WRONG_CURRENT_PASSWORD": "Incorrect current password.",
    "PAST_DATE": "Appointments cannot be booked for a past date.",
    "PATIENT_HAS_UPCOMING_APPOINTMENT": "This patient already has an upcoming appointment: it must be cancelled before booking another one.",
    "CAPTCHA_BEFORE_ACCOUNT": "To create an account, first type the characters shown in the image correctly, then click \"Create a patient account\".",
    "AUTH_REQUIRED": "Please log in to access this page.",
    "JSON_REQUIRED": "Request refused: the server expects JSON.",
    "SERVER_UNREACHABLE": "The server does not respond. Please try again later."
  },
  "CAPTCHA": {
    "LABEL": "Type the characters shown in the image",
    "IMAGE_ALT": "Anti-robot verification image",
    "NEW_IMAGE": "Another image",
    "HELP": "Upper or lower case, it does not matter."
  },
  "INSCRIPTION": {
    "TITLE": "Create a patient account",
    "INTRO": "With this account you can book your own appointments.",
    "LOGIN_HELP": "Unaccented lower-case letters, digits, dot, hyphen, underscore.",
    "PASSWORD_HELP": "At least {{min}} characters.",
    "SUBMIT": "Create my account",
    "BACK_TO_LOGIN": "Back to login",
    "DONE": "Your account has been created. You can now log in with the login {{login}}."
  },
  "COMPTE": {
    "CONFIRMATION_LABEL": "Confirm password"
  },
  "ESPACE": {
    "TITLE": "My space",
    "HELLO": "Hello {{nom}}",
    "MANAGE_ACCOUNT": "Manage my account",
    "MANAGE_ACCOUNT_HELP": "Change my details or password, or delete my account.",
    "VIEW_AGENDA": "View a schedule",
    "VIEW_AGENDA_HELP": "See a doctor's schedule and book a slot.",
    "MY_APPOINTMENT": "My appointment",
    "SEE_IN_AGENDA": "see in the schedule",
    "CANCEL_APPOINTMENT": "Cancel this appointment",
    "NO_APPOINTMENT": "You have no upcoming appointment."
  },
  "MON_COMPTE": {
    "TITLE": "Manage my account",
    "NEW_PASSWORD_LABEL": "New password",
    "CURRENT_PASSWORD_LABEL": "Current password",
    "CURRENT_PASSWORD_HELP": "Required to save any change.",
    "BACK": "Back to my space",
    "DELETE_TITLE": "Delete my account",
    "DELETE_HELP": "Your account will be permanently deleted, together with all your appointments.",
    "DELETE_CONFIRM": "Permanently delete your account? Any appointment you have will be cancelled.",
    "DELETE_SUBMIT": "Delete my account",
    "UPDATED": "Your account has been updated.",
    "DELETED": "Your account has been deleted."
  },
  "CONFIRMATION": {
    "TITLE": "Confirmation",
    "NO": "No, go back",
    "YES": "Yes, confirm"
  }
}

10.4.5. Les magasins

10.4.5.1. src/stores/session.ts

// =========================================================================
// stores/session.ts - QUI est connecté ?
// -------------------------------------------------------------------------
// L'utilisateur connecté (ou null), dans un magasin Zustand (cf. exemple
// 20) : lisible par les composants (useSession) ET par le code qui n'est
// pas un composant (les gardes du routeur : useSession.getState()).
//
// Le jeton JWT lui-même n'est PAS ici : il est dans un cookie httpOnly que
// seul le navigateur manipule. Le magasin ne garde que la DESCRIPTION de
// l'utilisateur, renvoyée par le serveur ({ login, nom, role }).
//
// Au démarrage (et après un F5), le magasin est vide : verifier() demande
// au serveur, une seule fois, qui est connecté (GET /api/auth/moi).
// =========================================================================

import { create } from 'zustand';
import { api } from '../api/rdvmedecins.api';
import type { Role, Utilisateur } from '../api/types';

interface Session {
  utilisateur: Utilisateur | null;
  verifier: () => Promise<void>;
  connecter: (login: string, password: string, captchaId: string, captcha: string) => Promise<void>;
  mettreAJour: (u: Utilisateur) => void;
  deconnecter: () => Promise<void>;
  oublier: () => void;
}

// la question "qui est connecté ?" a-t-elle déjà été posée au serveur ?
// (une variable du module : elle n'a pas à être affichée)
let verification: Promise<void> | null = null;

export const useSession = create<Session>()((set, get) => ({
  utilisateur: null,

  // appelée par le garde du routeur avant CHAQUE navigation ; ne questionne
  // le serveur que la première fois (la promesse est gardée et réutilisée)
  verifier() {
    verification ??= api
      .moi()
      .then((u) => set({ utilisateur: u }))
      .catch(() => set({ utilisateur: null })); // 401 : personne ; ou serveur injoignable
    return verification;
  },

  async connecter(login, password, captchaId, captcha) {
    set({ utilisateur: await api.connecter(login, password, captchaId, captcha) });
  },

  // l'utilisateur a changé de nom ou de login (PUT /api/mon-compte)
  mettreAJour: (u) => set({ utilisateur: u }),

  async deconnecter() {
    try {
      await api.deconnecter();
    } finally {
      get().oublier();
    }
  },

  // sans rien demander au serveur : session expirée, compte supprimé
  oublier: () => set({ utilisateur: null }),
}));

// ce qu'on déduit de l'utilisateur : des fonctions ordinaires
export const role = (u: Utilisateur | null): Role | null => u?.role ?? null;
export const estAdmin = (u: Utilisateur | null) => role(u) === 'ADMIN';
export const estPatient = (u: Utilisateur | null) => role(u) === 'USER';
// ADMIN et DOCTOR : prennent et annulent les rendez-vous de n'importe quel patient
export const estGestionnaire = (u: Utilisateur | null) => role(u) === 'ADMIN' || role(u) === 'DOCTOR';
// la page d'accueil de chaque rôle, juste après la connexion
export const pageAccueil = (u: Utilisateur | null) => (estPatient(u) ? '/mon-espace' : '/agenda');

L'utilisateur connecté, dans un magasin [Zustand] (exemple 20). Un magasin est lisible par les composants ([useSession((s) => s.utilisateur)]) ET par le code qui n'est pas un composant : [useSession.getState()] (les gardes du routeur).

  • ligne 21 : la DESCRIPTION de l'utilisateur ([{ login, nom, role }]). Le jeton JWT, lui, est dans le cookie httpOnly : le client ne le voit jamais ;
  • lignes 31-44 : [verifier()] demande au serveur qui est connecté, UNE fois : la promesse est gardée et réutilisée ([??=] : affectation si la variable est [null]). Les gardes l'appellent avant chaque navigation : seule la première attend vraiment ;
  • lignes 46-48 : la connexion : l'utilisateur renvoyé par le serveur est rangé dans le magasin ;
  • lignes 53-59 : la déconnexion. [finally] : même si le serveur ne répond pas, le client oublie l'utilisateur ;
  • lignes 66-72 : ce qu'on déduit de l'utilisateur : des fonctions ordinaires, qui prennent l'utilisateur en paramètre. Un composant écrit [estAdmin(utilisateur)], après avoir lu [utilisateur] dans le magasin : il est alors ré-affiché quand l'utilisateur change.

10.4.5.2. src/stores/messages.ts

// =========================================================================
// stores/messages.ts - les messages affichés à l'utilisateur
// -------------------------------------------------------------------------
// Même présentation que l'application HTML :
//   - un SUCCÈS s'affiche en HAUT de la page (bandeau vert) ;
//   - une ERREUR en BAS, sur fond rose léger (zone "collante").
// Un message est une CLÉ de traduction (+ ses paramètres) : il se retraduit
// donc tout seul si l'utilisateur change de langue.
//
// Les messages "flash" : une action qui se termine par un changement de
// page (réservation, ajout d'un médecin...) dépose son message avec flash() ;
// il s'affichera sur la page SUIVANTE, puis disparaîtra à la navigation
// d'après. C'est le routeur qui appelle nouvellePage() à la fin de chaque
// navigation (cf. routeur.ts). C'était, côté serveur, le rôle du cookie de
// courte durée "rdvmedecins_flash".
// =========================================================================

import { create } from 'zustand';
import { ErreurApi } from '../api/erreur-api';

export interface Message {
  cle: string;
  params?: Record<string, string>;
}

interface Messages {
  succes: Message | null;
  erreur: Message | null;
  // les messages à afficher sur la page suivante
  enAttente: { succes: Message | null; erreur: Message | null } | null;
  afficherSucces: (cle: string, params?: Record<string, string>) => void;
  afficherErreur: (cle: string, params?: Record<string, string>) => void;
  afficherErreurApi: (e: unknown) => void;
  effacer: () => void;
  flash: (type: 'succes' | 'erreur', cle: string, params?: Record<string, string>) => void;
  nouvellePage: () => void;
}

export const useMessages = create<Messages>()((set) => ({
  succes: null,
  erreur: null,
  enAttente: null,

  afficherSucces: (cle, params) => set({ succes: { cle, params }, erreur: null }),
  afficherErreur: (cle, params) => set({ erreur: { cle, params }, succes: null }),

  // une ErreurApi -> le message de la page ; toute autre erreur est une
  // anomalie du client, qu'on laisse remonter (console du navigateur)
  afficherErreurApi(e) {
    if (!(e instanceof ErreurApi)) {
      throw e;
    }
    set({ erreur: { cle: e.cle, params: e.params }, succes: null });
  },

  effacer: () => set({ succes: null, erreur: null }),

  // pour la page SUIVANTE
  flash: (type, cle, params) =>
    set((m) => ({ enAttente: { succes: null, erreur: null, ...m.enAttente, [type]: { cle, params } } })),

  // une nouvelle page : les messages de la précédente disparaissent, les
  // messages flash apparaissent
  nouvellePage: () =>
    set((m) => ({ succes: m.enAttente?.succes ?? null, erreur: m.enAttente?.erreur ?? null, enAttente: null })),
}));

Les messages affichés à l'utilisateur : un succès en haut de la page, une erreur en bas.

  • lignes 21-24 : un message est une CLÉ de traduction et ses paramètres, pas un texte : il se retraduit tout seul si l'utilisateur change de langue ;
  • lignes 49-54 : une [ErreurApi] devient le message d'erreur de la page. Toute autre erreur est une anomalie du client (une faute de programmation) : on la relance, pour qu'elle apparaisse dans la console du navigateur ;
  • lignes 59-60 : [flash] dépose un message pour la page SUIVANTE. Une action qui se termine par un changement de page (l'ajout d'un médecin, suivi du retour à la liste) l'utilise : un message affiché tout de suite disparaîtrait avec la navigation ;
  • lignes 64-65 : appelée par le routeur à la fin de chaque navigation : les messages en attente deviennent les messages de la page.

10.4.5.3. src/stores/confirmation.ts

// =========================================================================
// stores/confirmation.ts - « Êtes-vous sûr ? »
// -------------------------------------------------------------------------
// Repris de l'exemple 16, sous la forme d'un magasin : confirmer(cle) ouvre
// la fenêtre de confirmation (components/FenetreConfirmation.tsx, placée une
// fois dans le gabarit) et renvoie une promesse résolue à true (Oui) ou
// false (Non). Le message est une CLÉ de traduction : la fenêtre le traduit
// dans la langue courante.
//
//     if (await confirmer('AGENDA.CANCEL_CONFIRM')) { ... }
// =========================================================================

import { create } from 'zustand';
import { useMessages } from './messages';

interface Confirmation {
  cleMessage: string | null; // null : fenêtre fermée
  reponse: (oui: boolean) => void;
}

// la fonction qui résoudra la promesse en cours (rien à afficher : hors de l'état)
let repondre: ((oui: boolean) => void) | null = null;

export const useConfirmation = create<Confirmation>()((set) => ({
  cleMessage: null,
  reponse(oui) {
    set({ cleMessage: null });
    const r = repondre;
    repondre = null;
    r?.(oui);
  },
}));

// une fonction ordinaire, utilisable partout
export function confirmer(cle: string): Promise<boolean> {
  repondre?.(false); // une question encore ouverte ? elle vaut "non"
  // une nouvelle question : les messages de l'action précédente sont périmés
  useMessages.getState().effacer();
  useConfirmation.setState({ cleMessage: cle });
  return new Promise<boolean>((resolve) => {
    repondre = resolve;
  });
}

La fenêtre de confirmation de l'exemple 16, sous la forme d'un magasin et d'une fonction ordinaire :

  • ligne 17 : la question posée (une clé de traduction), ou [null] : la fenêtre ([components/FenetreConfirmation.tsx]) est fermée ;
  • ligne 22 : la fonction qui résoudra la promesse de la question en cours. Rien à afficher : ce n'est pas un état, mais une variable du module ;
  • lignes 35-43 : [confirmer] ouvre la fenêtre et renvoie une promesse, résolue à [true] (Oui) ou [false] (Non) par [reponse]. Une page écrit simplement [if (await confirmer('AGENDA.CANCEL_CONFIRM')) { ... }]. Pas besoin d'un contexte comme à l'exemple 16 : le magasin est accessible partout.

10.4.6. Les hooks

10.4.6.1. src/hooks/useEnvoiFormulaire.ts

// =========================================================================
// hooks/useEnvoiFormulaire.ts - envoyer un formulaire au serveur
// -------------------------------------------------------------------------
// Tous les formulaires de l'application suivent le même schéma que
// l'exemple 25 : aucune règle n'est vérifiée dans le navigateur (elles
// vivent en UN seul endroit, le serveur), et en cas de refus :
//   - l'erreur de chaque champ (ErreurApi.champs) est affichée sous le
//     champ, encadré de rouge ;
//   - le message général (ErreurApi.cle) s'affiche en bas de la page.
// Ce hook regroupe ces gestes. Une page l'appelle une fois par formulaire :
//
//     const envoi = useEnvoiFormulaire();
//     if (await envoi.envoyer(() => api.inscrire(saisie))) { ... }
//     <ChampSaisie ... erreur={envoi.erreurs.nom} />
// =========================================================================

import { useCallback, useState } from 'react';
import { ErreurApi } from '../api/erreur-api';
import { useMessages } from '../stores/messages';

export function useEnvoiFormulaire() {
  const afficherErreurApi = useMessages((m) => m.afficherErreurApi);
  // l'erreur de chaque champ : une CLÉ de traduction
  const [erreurs, setErreurs] = useState<Record<string, string>>({});
  // les paramètres des messages d'erreur ({min}...), renvoyés par le serveur
  const [params, setParams] = useState<Record<string, string>>({});
  const [enCours, setEnCours] = useState(false);

  // exécute l'envoi ; renvoie true s'il a réussi. En cas d'ErreurApi, les
  // erreurs sont affichées et on renvoie false.
  const envoyer = useCallback(
    async (action: () => Promise<unknown>): Promise<boolean> => {
      setErreurs({});
      setParams({});
      setEnCours(true);
      try {
        await action();
        return true;
      } catch (e) {
        afficherErreurApi(e); // (relance toute erreur qui n'est pas une ErreurApi)
        const erreur = e as ErreurApi;
        setErreurs(erreur.champs);
        setParams(erreur.params);
        return false;
      } finally {
        setEnCours(false);
      }
    },
    [afficherErreurApi],
  );

  // l'utilisateur modifie un champ : son erreur disparaît
  const effacerErreur = useCallback((champ: string) => {
    setErreurs((e) => {
      if (!(champ in e)) return e; // rien à effacer : le MÊME objet (pas de nouvel affichage)
      const { [champ]: _efface, ...reste } = e;
      return reste;
    });
  }, []);

  return { erreurs, params, enCours, envoyer, effacerErreur };
}

Tous les formulaires de l'application suivent le schéma de l'exemple 25 : aucune règle n'est vérifiée dans le navigateur, et en cas de refus du serveur, l'erreur de chaque champ s'affiche sous le champ, le message général en bas de la page. Ce hook personnalisé (exemple 15) regroupe ces gestes :

  • lignes 24-27 : son état : l'erreur de chaque champ (une clé de traduction), leurs paramètres, et « envoi en cours » (pour désactiver le bouton) ;
  • lignes 31-50 : [envoyer(action)] exécute l'envoi et renvoie [true] s'il a réussi. En cas d'[ErreurApi], les erreurs s'affichent et la fonction renvoie [false]. [useCallback] : la fonction reste la MÊME d'un affichage à l'autre ;
  • lignes 53-59 : l'utilisateur modifie un champ : son erreur disparaît. Si le champ n'avait pas d'erreur, on renvoie le MÊME objet : [React] ne ré-affiche rien. [const { [champ]: _efface, ...reste } = e] : [reste] est une copie de [e], sans la propriété [champ].

10.4.6.2. src/hooks/useCaptcha.ts

// =========================================================================
// hooks/useCaptcha.ts - une image de captcha, et le moyen d'en changer
// -------------------------------------------------------------------------
// Charge un captcha (GET /api/captcha) à l'affichage du composant qui
// l'utilise. recharger() en demande un autre : il le faut après CHAQUE
// tentative, car une image ne sert qu'UNE fois (le serveur l'oublie dès
// qu'il l'a vérifiée). La page de connexion l'utilise avec le composant
// ChampCaptcha, qui affiche l'image et le champ de saisie.
// =========================================================================

import { useCallback, useEffect, useState } from 'react';
import { api } from '../api/rdvmedecins.api';
import type { Captcha } from '../api/types';

export function useCaptcha() {
  const [captcha, setCaptcha] = useState<Captcha | null>(null);

  const recharger = useCallback(async () => {
    try {
      setCaptcha(await api.captcha());
    } catch {
      setCaptcha(null); // serveur injoignable : la connexion échouera avec un message clair
    }
  }, []);

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

  // captchaId : l'identifiant de l'image affichée, à envoyer avec la réponse
  return { svg: captcha?.svg ?? '', captchaId: captcha?.id ?? '', recharger };
}

Une image de captcha, chargée à l'affichage du composant, et [recharger()] pour en demander une autre : il le faut après CHAQUE tentative, car le serveur oublie une image dès qu'il l'a vérifiée.

10.4.7. La mise en forme

10.4.7.1. src/formatage.ts

// =========================================================================
// formatage.ts - l'affichage des personnes, heures et jours
// -------------------------------------------------------------------------
// Le serveur envoie des données BRUTES (hdebut: 8, mdebut: 0 ; jour:
// "2026-10-05") ; la mise en forme, qui dépend de la langue, est l'affaire
// du client (cf. exemple 09).
// =========================================================================

import type { Creneau, Identite } from './api/types';

// "Mme Alice DURAND"
export function libelle(p: Identite): string {
  return `${p.titre} ${p.prenom} ${p.nom}`;
}

// "8:05" plutôt que "8:5"
export function heure(h: number, m: number): string {
  return `${h}:${String(m).padStart(2, '0')}`;
}

// "8:00 - 8:20"
export function horaire(c: Creneau): string {
  return `${heure(c.hdebut, c.mdebut)} - ${heure(c.hfin, c.mfin)}`;
}

// la date du jour, au format "yyyy-MM-dd" (celui de <input type="date"> et de l'API)
export function aujourdhui(): string {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

// vrai si [jour] est une date qui existe au format yyyy-MM-dd
export function estUneDateValide(jour: unknown): jour is string {
  if (typeof jour !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(jour)) {
    return false;
  }
  const date = new Date(`${jour}T00:00:00Z`);
  return !isNaN(date.getTime()) && date.toISOString().slice(0, 10) === jour;
}

// "lundi 5 octobre 2026" / "Monday 5 October 2026"
export function jourLong(jour: string, langue: string): string {
  return new Intl.DateTimeFormat(langue === 'en' ? 'en-GB' : 'fr-FR', {
    dateStyle: 'full',
    timeZone: 'UTC', // "2026-10-05T00:00:00Z" doit rester le 5, quel que soit le fuseau
  }).format(new Date(`${jour}T00:00:00Z`));
}

Le serveur envoie des données brutes ([hdebut: 8, mdebut: 0] ; [jour: "2026-10-05"]) ; la mise en forme est l'affaire du client (exemple 09). Ce sont des fonctions ordinaires : [React] n'a pas besoin de « pipes », une expression JSX peut appeler n'importe quelle fonction ([{horaire(c)}]).

  • lignes 33-39 : [jour is string] : un prédicat de type : si la fonction renvoie [true], [TypeScript] sait ensuite que [jour] est une chaîne. Une date « qui existe » : [2026-02-30] a la bonne forme, mais [new Date] la transforme en 2 mars ; on compare donc la date obtenue à la chaîne de départ ;
  • lignes 42-47 : le jour en toutes lettres, dans la langue de l'utilisateur, avec [Intl.DateTimeFormat]. [timeZone: 'UTC'] : la date est construite à minuit UTC ; formatée dans le fuseau de la Martinique (UTC-4), ce serait encore la veille.

10.4.8. Le routeur

10.4.8.1. src/routes.tsx

// =========================================================================
// routes.tsx - les pages de l'application et leurs droits d'accès
// -------------------------------------------------------------------------
// Les MÊMES adresses que l'application HTML (/login, /agenda, /medecins,
// /mon-espace...), mais ce sont maintenant des routes du CLIENT. Le
// navigateur n'envoie une requête au serveur que pour la toute première
// page ; ensuite, le routeur change l'adresse et le composant affiché.
//
// Revers de la médaille : si l'utilisateur recharge /agenda (F5), le
// navigateur demande bel et bien /agenda au serveur, qui doit alors
// renvoyer index.html (Vite le fait en développement ; en production,
// cf. web/client-spa.ts du serveur JSON).
//
// Toutes les pages sont les ENFANTS de la route racine, dont le composant,
// Gabarit, les affiche dans son <Outlet />. La fonction page() décrit une
// page : son adresse, son composant (chargé à la demande pour les pages
// qu'on ne visite pas toujours), et le garde qui vérifie ses droits.
// =========================================================================

import type { ComponentType } from 'react';
import { redirect, type MiddlewareFunction, type RouteObject } from 'react-router';
import type { TypePersonnes } from './api/rdvmedecins.api';
import { garde, gardeInscription, type Droits } from './gardes';
import Gabarit from './components/Gabarit';
import Connexion from './pages/Connexion';
import Agenda from './pages/Agenda';
import PageErreur from './pages/PageErreur';

// une page : composant chargé tout de suite (Component) ou à la demande
// (charger) ; autresGardes : des gardes propres à la page, après le garde commun
interface Page {
  path: string;
  droits: Droits;
  Component?: ComponentType;
  charger?: () => Promise<ComponentType>;
  autresGardes?: MiddlewareFunction[];
}

function page({ path, droits, Component, charger, autresGardes = [] }: Page): RouteObject {
  return {
    path,
    // les gardes s'exécutent AVANT le chargement de la page
    middleware: [garde(droits), ...autresGardes],
    ...(charger ? { lazy: async () => ({ Component: await charger() }) } : { Component }),
  };
}

// les pages de gestion des médecins ET des clients (ADMIN), sur le même
// modèle ; le composant reçoit le type en prop
function pagesPersonnes(type: TypePersonnes): RouteObject[] {
  const droits: Droits = { roles: ['ADMIN'] };
  const liste = async () => {
    const { default: Personnes } = await import('./pages/Personnes');
    return () => <Personnes type={type} />;
  };
  const formulaire = async () => {
    const { default: PersonneFormulaire } = await import('./pages/PersonneFormulaire');
    return () => <PersonneFormulaire type={type} />;
  };
  return [
    page({ path: type, droits, charger: liste }),
    page({ path: `${type}/ajouter`, droits, charger: formulaire }),
    page({ path: `${type}/:id/modifier`, droits, charger: formulaire }),
  ];
}

export const routes: RouteObject[] = [
  {
    path: '/',
    Component: Gabarit,
    // affiché pendant la toute première navigation (le temps de demander au
    // serveur qui est connecté)
    HydrateFallback: () => null,
    children: [
      // "/" : vers /login, qui renvoie lui-même à la page d'accueil du rôle si l'on est connecté
      // (un composant qui n'affiche rien : la redirection a lieu avant)
      { index: true, loader: () => redirect('/login'), Component: () => null },
      page({ path: 'login', droits: { public: true }, Component: Connexion }),
      page({ path: 'agenda', droits: {}, Component: Agenda }),
      ...pagesPersonnes('medecins'),
      ...pagesPersonnes('clients'),
      page({
        path: 'inscription',
        droits: { public: true },
        charger: async () => (await import('./pages/Inscription')).default,
        autresGardes: [gardeInscription],
      }),
      page({ path: 'mon-espace', droits: { roles: ['USER'] }, charger: async () => (await import('./pages/EspacePatient')).default }),
      page({ path: 'mon-compte', droits: { roles: ['USER'] }, charger: async () => (await import('./pages/MonCompte')).default }),
      page({ path: 'interdit', droits: {}, Component: () => <PageErreur code={403} /> }),
      page({ path: '*', droits: { public: true }, Component: () => <PageErreur code={404} /> }),
    ],
  },
];

Les MÊMES adresses que l'application HTML, mais ce sont maintenant des routes du CLIENT :

  • lignes 31-37 : la description d'une page : son adresse, ses droits d'accès, et son composant, donné tout de suite ([Component]) ou chargé à la demande ([charger]) ;
  • lignes 39-46 : [page()] fabrique la route correspondante :
    • ligne 43 : le garde commun, fabriqué d'après les droits de la page, puis les gardes propres à la page. Ils s'exécutent AVANT le chargement de la page (exemple 18) ;
    • ligne 44 : [lazy] : le code de la page est chargé à la première visite (exemple 17). La page de connexion et l'agenda, que tout le monde voit, sont chargés tout de suite ; les pages de l'ADMIN et du patient, à la demande ;
  • lignes 50-65 : les trois pages de gestion des médecins, puis des clients, sur le même modèle. Le composant est enveloppé dans un composant sans paramètre, [() => <Personnes type={type} />] : c'est ainsi qu'il reçoit le type en prop ;
  • lignes 69-70 : la route racine. Son composant, [Gabarit], affiche la page courante dans son [<Outlet />] ;
  • ligne 73 : ce qu'on affiche pendant la toute première navigation, le temps que le garde demande au serveur qui est connecté : rien ;
  • ligne 77 : l'adresse [/] renvoie à [/login], dont le garde renvoie lui-même à la page d'accueil du rôle si l'on est connecté ;
  • lignes 78-79 : [droits: { public: true }] : la page est accessible sans être connecté ; [droits: {}] : il faut être connecté, quel que soit le rôle ;
  • lignes 83-87 : la création d'un compte, publique, a un second garde, [gardeInscription] ;
  • lignes 88-89 : [roles: ['USER']] : les pages du patient ;
  • ligne 90 : la page 403 ;
  • ligne 91 : la page 404 : [*] désigne toute adresse qu'aucune autre route ne reconnaît.

Rappel : si l'utilisateur recharge [/agenda] (F5), le navigateur demande bel et bien [/agenda] au serveur, qui doit alors renvoyer [index.html] : c'est ce que fait le serveur de développement de [Vite], et, en production, le serveur JSON (cf. chapitre « Déploiement »).

10.4.8.2. src/gardes.ts

// =========================================================================
// gardes.ts - les gardes du routeur (des middlewares, cf. exemple 18)
// -------------------------------------------------------------------------
// garde(droits) FABRIQUE le garde d'une page, d'après ses droits d'accès :
//   - public : accessible sans être connecté ;
//   - roles  : les rôles autorisés (absent = tous les rôles connectés).
// Chaque page déclare le sien (cf. routes.tsx). Avant chaque navigation
// vers cette page, le garde :
//   1. la première fois (démarrage, F5), demande au serveur qui est
//      connecté (useSession.getState().verifier()) ;
//   2. vérifie les droits de la page, et redirige si besoin.
//
// RAPPEL : ces contrôles ne sont qu'un CONFORT (ne pas afficher une page
// inutile). La protection des données est assurée par le serveur, qui
// revérifie le jeton et le rôle à CHAQUE requête.
// =========================================================================

import { redirect, type MiddlewareFunction } from 'react-router';
import type { Role } from './api/types';
import { api } from './api/rdvmedecins.api';
import { pageAccueil, useSession } from './stores/session';
import { useMessages } from './stores/messages';

export interface Droits {
  public?: boolean;
  roles?: Role[];
}

export function garde(droits: Droits): MiddlewareFunction {
  return async ({ request }) => {
    await useSession.getState().verifier();
    // getState() : l'état du magasin, lu HORS d'un composant
    const utilisateur = useSession.getState().utilisateur;
    const url = new URL(request.url);

    if (droits.public) {
      // déjà connecté : la page de connexion n'a plus de sens
      if (url.pathname === '/login' && utilisateur !== null) {
        throw redirect(pageAccueil(utilisateur));
      }
      return;
    }
    if (utilisateur === null) {
      // on retient la page demandée, pour y revenir après la connexion
      throw redirect(`/login?redirect=${encodeURIComponent(url.pathname + url.search)}`);
    }
    if (droits.roles && !droits.roles.includes(utilisateur.role)) {
      throw redirect('/interdit');
    }
  };
}

// un garde PROPRE à la route /inscription : sans laissez-passer (captcha
// résolu sur la page de connexion), retour à la page de connexion
export const gardeInscription: MiddlewareFunction = async () => {
  const { valide } = await api.etatAccesInscription().catch(() => ({ valide: false }));
  if (!valide) {
    useMessages.getState().flash('erreur', 'ERRORS.CAPTCHA_BEFORE_ACCOUNT');
    throw redirect('/login');
  }
};
  • lignes 24-27 : les droits d'accès d'une page ;
  • lignes 29-51 : [garde(droits)] FABRIQUE un garde, c'est-à-dire un middleware du routeur (exemple 18) :
    • ligne 31 : la première fois (démarrage, F5), on demande au serveur qui est connecté ; les fois suivantes, la réponse est déjà là ;
    • lignes 36-42 : une page publique. Un utilisateur connecté n'a plus rien à faire sur la page de connexion : on l'envoie sur sa page d'accueil ;
    • lignes 43-46 : personne n'est connecté : direction la page de connexion, en retenant la page demandée ([?redirect=...]), pour y revenir après la connexion ;
    • lignes 47-49 : le rôle n'est pas autorisé : la page 403 ;
    • [throw redirect(...)] : un garde « lance » une redirection : la navigation en cours est abandonnée au profit de la nouvelle adresse ;
  • lignes 55-61 : le garde propre à la page [/inscription] : sans laissez-passer (le captcha résolu sur la page de connexion), retour à la page de connexion, avec un message. Le serveur, lui, refuserait de toute façon la création du compte.

Ces gardes ne sont qu'un CONFORT : ne pas afficher une page inutile. La protection des données est assurée par le serveur, qui revérifie le jeton et le rôle à CHAQUE requête.

10.4.9. Les composants

10.4.9.1. src/components/Gabarit.tsx

// =========================================================================
// components/Gabarit.tsx - le GABARIT commun à toutes les pages
// -------------------------------------------------------------------------
// Le composant de la route racine (cf. routes.tsx), l'équivalent de
// views/layout.hbs de l'application HTML :
//   - la barre de navigation ;
//   - un message de succès en HAUT ;
//   - la page courante (<Outlet />) ;
//   - la zone d'erreur en BAS ;
//   - la fenêtre de confirmation, une fois pour toute l'application.
// =========================================================================

import { Outlet } from 'react-router';
import BarreNavigation from './BarreNavigation';
import MessageSucces from './MessageSucces';
import ZoneErreur from './ZoneErreur';
import FenetreConfirmation from './FenetreConfirmation';

export default function Gabarit() {
  return (
    <>
      <div className="container py-4">
        <BarreNavigation />
        <MessageSucces />
        <Outlet />
        <ZoneErreur />
      </div>
      <FenetreConfirmation />
    </>
  );
}

Le composant de la route racine, l'équivalent de [views/layout.hbs] de l'application HTML : la barre de navigation, le message de succès en haut, la page courante ([<Outlet />]), la zone d'erreur en bas. La fenêtre de confirmation y est placée une fois pour toute l'application.

10.4.9.2. src/components/TitrePage.tsx

// =========================================================================
// components/TitrePage.tsx - le titre de l'onglet, traduit
// -------------------------------------------------------------------------
// React 19 place dans le <head> de la page tout élément <title> rendu par
// un composant (cf. exemple 17). Chaque page affiche <TitrePage cle="..." />
// avec la clé de son titre : l'onglet affiche "Agenda - RdvMedecins", et
// se retraduit quand la langue change.
// =========================================================================

import { useTranslation } from 'react-i18next';

export default function TitrePage({ cle }: { cle: string }) {
  const { t } = useTranslation();
  return <title>{`${t(cle)} - ${t('APP.TITLE')}`}</title>;
}

[React] 19 place dans l'en-tête de la page ([<head>]) tout élément [<title>] rendu par un composant (exemple 17). Chaque page affiche [<TitrePage cle="AGENDA.PAGE_TITLE" />] : l'onglet affiche « Agenda - RdvMedecins », et se retraduit quand la langue change.

10.4.9.3. src/components/BarreNavigation.tsx

// =========================================================================
// components/BarreNavigation.tsx - la barre de navigation
// -------------------------------------------------------------------------
// Même contenu que celle de l'application HTML (views/partials/navbar.hbs) :
// titre, menus selon le rôle, FR / EN, rôle, nom, déconnexion.
// "Médecins" et "Clients" n'apparaissent que pour l'ADMIN : un simple
// confort d'affichage, la vraie protection étant celle du serveur.
// =========================================================================

import { NavLink, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { LANGUES } from '../i18n/i18n';
import { estAdmin, estPatient, useSession } from '../stores/session';

export default function BarreNavigation() {
  const { t, i18n } = useTranslation();
  const utilisateur = useSession((s) => s.utilisateur);
  const deconnecter = useSession((s) => s.deconnecter);
  const navigate = useNavigate();

  async function seDeconnecter() {
    await deconnecter().catch(() => {}); // même si le serveur ne répond pas
    navigate('/login');
  }

  return (
    <nav className="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
      <span className="navbar-brand mb-0">{t('APP.TITLE')}</span>

      {utilisateur ? (
        <ul className="navbar-nav me-auto">
          {estPatient(utilisateur) && (
            <li className="nav-item">
              {/* NavLink pose la classe "active" (Bootstrap) sur le menu de la page affichée */}
              <NavLink className="nav-link" to="/mon-espace">{t('APP.MENU_MY_SPACE')}</NavLink>
            </li>
          )}
          <li className="nav-item">
            <NavLink className="nav-link" to="/agenda">{t('APP.MENU_AGENDA')}</NavLink>
          </li>
          {estAdmin(utilisateur) && (
            <>
              <li className="nav-item">
                <NavLink className="nav-link" to="/medecins">{t('APP.MENU_DOCTORS')}</NavLink>
              </li>
              <li className="nav-item">
                <NavLink className="nav-link" to="/clients">{t('APP.MENU_PATIENTS')}</NavLink>
              </li>
            </>
          )}
        </ul>
      ) : (
        <div className="me-auto"></div>
      )}

      <div className="d-flex align-items-center gap-2">
        {/* FR / EN : aucune requête au serveur, la page se retraduit sur place */}
        <div className="btn-group btn-group-sm" role="group" aria-label={t('APP.LANGUAGE')}>
          {LANGUES.map((l) => (
            <button key={l} type="button" className={`btn ${i18n.language === l ? 'btn-light' : 'btn-outline-light'}`}
                    aria-pressed={i18n.language === l} onClick={() => i18n.changeLanguage(l)}>
              {l.toUpperCase()}
            </button>
          ))}
        </div>

        {utilisateur && (
          <>
            <span className="badge text-bg-light text-primary">{utilisateur.role}</span>
            <span className="text-white">{utilisateur.nom}</span>
            <button type="button" className="btn btn-sm btn-outline-light" onClick={seDeconnecter}>{t('APP.LOGOUT')}</button>
          </>
        )}
      </div>
    </nav>
  );
}
  • lignes 17-18 : le composant lit l'utilisateur et la fonction de déconnexion dans le magasin de session : il est ré-affiché quand l'utilisateur change (connexion, déconnexion, session expirée) ;
  • lignes 21-24 : la déconnexion, puis la page de connexion. Même si le serveur ne répond pas, le client oublie l'utilisateur (cf. [stores/session.ts]) : on ignore l'erreur ([catch(() => {})]) ;
  • lignes 32-37 : les menus dépendent du rôle ;
  • ligne 35 : [NavLink] (exemple 17) pose la classe [active] de [Bootstrap] sur le menu de la page affichée ;
  • lignes 59-64 : les boutons FR / EN. [i18n.changeLanguage(l)] : aucune requête au serveur, toute la page se retraduit sur place. [aria-pressed] : un lecteur d'écran annonce le bouton de la langue courante comme « enfoncé » ;
  • lignes 67-73 : le rôle, le nom, et le bouton de déconnexion.

10.4.9.4. src/components/MessageSucces.tsx et ZoneErreur.tsx

// =========================================================================
// components/MessageSucces.tsx - le message de SUCCÈS, en haut de la page
// (rendez-vous enregistré, médecin ajouté...) - cf. stores/messages.ts.
// role="status" : un lecteur d'écran l'annonce sans interrompre l'utilisateur.
// =========================================================================

import { useTranslation } from 'react-i18next';
import { useMessages } from '../stores/messages';

export default function MessageSucces() {
  const { t } = useTranslation();
  const succes = useMessages((m) => m.succes);
  return succes && <div className="alert alert-success" role="status">{t(succes.cle, succes.params)}</div>;
}
// =========================================================================
// components/ZoneErreur.tsx - la zone d'erreur, en BAS de chaque page, sur
// fond rose léger (classe .zone-erreur, cf. styles.css) - comme
// views/partials/erreurs.hbs de l'application HTML.
// role="alert" : un lecteur d'écran annonce le message dès son apparition.
// =========================================================================

import { useTranslation } from 'react-i18next';
import { useMessages } from '../stores/messages';

export default function ZoneErreur() {
  const { t } = useTranslation();
  const erreur = useMessages((m) => m.erreur);
  return erreur && <div className="zone-erreur" role="alert">{t(erreur.cle, erreur.params)}</div>;
}

Deux petits composants qui affichent les messages du magasin [useMessages], traduits dans la langue courante. [role="status"] et [role="alert"] : un lecteur d'écran annonce le message dès son apparition, discrètement pour un succès, immédiatement pour une erreur. Un composant peut renvoyer [null] (ou [false]) : il n'affiche alors rien.

10.4.9.5. src/components/FenetreConfirmation.tsx

// =========================================================================
// components/FenetreConfirmation.tsx
// -------------------------------------------------------------------------
// La fenêtre « Êtes-vous sûr ? » de l'exemple 16, complétée pour le clavier
// comme public/js/app.js de l'application HTML :
//   - à l'ouverture, le focus va sur "Non, revenir" : un appui distrait sur
//     Entrée ne supprime rien ;
//   - Échap ferme la fenêtre (= Non) ;
//   - Tab et Maj+Tab restent DANS la fenêtre (sinon le focus partirait sur
//     la page grisée, derrière) ;
//   - à la fermeture, le focus revient à l'élément qui l'avait (le bouton
//     "Supprimer" cliqué, par exemple).
// "Non, revenir" plutôt qu'"Annuler" : la question est parfois « Annuler ce
// rendez-vous ? », et un bouton "Annuler" y serait ambigu.
// Le composant est placé une fois dans le gabarit ; il n'affiche rien tant
// que le magasin de confirmation n'a pas de question (cf. stores/confirmation.ts).
// =========================================================================

import { useEffect, useRef, type KeyboardEvent, type MouseEvent } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useConfirmation } from '../stores/confirmation';

export default function FenetreConfirmation() {
  const { t } = useTranslation();
  const cle = useConfirmation((c) => c.cleMessage);
  const reponse = useConfirmation((c) => c.reponse);
  const fenetre = useRef<HTMLDivElement>(null);
  const boutonNon = useRef<HTMLButtonElement>(null);

  // la fenêtre s'ouvre : on retient l'élément qui a le focus et on le donne
  // au bouton "Non" ; elle se ferme (nettoyage) : on rend le focus
  const ouverte = cle !== null;
  useEffect(() => {
    if (!ouverte) return;
    const focusPrecedent = document.activeElement as HTMLElement | null;
    boutonNon.current?.focus();
    return () => focusPrecedent?.focus();
  }, [ouverte]);

  if (cle === null) {
    return null; // fenêtre fermée : rien à afficher
  }

  function clavier(e: KeyboardEvent<HTMLDivElement>) {
    if (e.key === 'Escape') {
      reponse(false);
    } else if (e.key === 'Tab' && fenetre.current) {
      const boutons = [...fenetre.current.querySelectorAll('button')];
      const premier = boutons[0];
      const dernier = boutons[boutons.length - 1];
      if (e.shiftKey && document.activeElement === premier) {
        e.preventDefault();
        dernier?.focus();
      } else if (!e.shiftKey && document.activeElement === dernier) {
        e.preventDefault();
        premier?.focus();
      }
    }
  }

  // un clic sur le fond grisé (et non dans la fenêtre elle-même) = Non
  function clicFond(e: MouseEvent<HTMLDivElement>) {
    if (e.target === e.currentTarget) {
      reponse(false);
    }
  }

  return createPortal(
    <div onKeyDown={clavier}>
      <div className="modal-backdrop fade show"></div>
      <div ref={fenetre} className="modal fade show d-block" tabIndex={-1} role="alertdialog" aria-modal="true"
           aria-labelledby="confirmation-titre" aria-describedby="confirmation-message" onClick={clicFond}>
        <div className="modal-dialog modal-dialog-centered">
          <div className="modal-content">
            <div className="modal-header">
              <h5 id="confirmation-titre" className="modal-title">{t('CONFIRMATION.TITLE')}</h5>
              <button type="button" className="btn-close" aria-label={t('BOOKING_DIALOG.CLOSE_ARIA')} onClick={() => reponse(false)}></button>
            </div>
            <div className="modal-body">
              <p id="confirmation-message" className="mb-0">{t(cle)}</p>
            </div>
            <div className="modal-footer">
              <button ref={boutonNon} type="button" className="btn btn-secondary" onClick={() => reponse(false)}>
                {t('CONFIRMATION.NO')}
              </button>
              <button type="button" className="btn btn-danger" onClick={() => reponse(true)}>{t('CONFIRMATION.YES')}</button>
            </div>
          </div>
        </div>
      </div>
    </div>,
    document.body,
  );
}

La fenêtre de l'exemple 16, reliée au magasin de confirmation, et complétée pour le clavier :

  • lignes 26-27 : la question posée ([null] : fenêtre fermée) et la fonction qui y répond ;
  • lignes 33-39 : un effet qui dépend de [ouverte] : à l'ouverture, on retient l'élément qui a le focus (le bouton « Supprimer » cliqué), et on donne le focus au bouton « Non, revenir » : un appui distrait sur Entrée ne supprime rien. Le nettoyage de l'effet, exécuté à la fermeture, rend le focus à l'élément retenu ;
  • lignes 41-43 : fenêtre fermée : rien à afficher. Les hooks sont appelés AVANT ce [return] : ils doivent l'être à chaque affichage, dans le même ordre ;
  • lignes 45-60 : Échap vaut « Non ». Tab et Maj+Tab restent DANS la fenêtre : du dernier bouton, Tab revient au premier, et inversement ; sinon le focus partirait sur la page grisée, derrière ;
  • lignes 63-67 : un clic sur le fond grisé vaut « Non ». [e.target] est l'élément cliqué, [e.currentTarget] celui qui porte le gestionnaire : ils sont égaux si le clic a eu lieu sur le fond lui-même, et non dans la fenêtre ;
  • lignes 69-94 : [createPortal] (exemple 16) affiche la fenêtre à la fin de [<body>], hors de la page : aucun style de la page ne peut la déformer ;
  • ligne 72 : pour un lecteur d'écran, une fenêtre qui demande une réponse urgente ; [aria-labelledby] et [aria-describedby] désignent son titre et sa question.

10.4.9.6. src/components/ChampSaisie.tsx

// =========================================================================
// components/ChampSaisie.tsx - un champ de formulaire complet
// -------------------------------------------------------------------------
// Libellé + champ + message d'aide OU message d'erreur. Tous les
// formulaires de l'application l'utilisent :
//
//   <ChampSaisie idChamp="nom" libelle="PERSONNES.LASTNAME_LABEL" longueurMax={20}
//                valeur={saisie.nom} onChange={(v) => modifier('nom', v)} erreur={envoi.erreurs.nom} />
//
// C'est un composant CONTRÔLÉ (cf. exemple 11) : la valeur appartient au
// formulaire parent. L'erreur est celle renvoyée par le SERVEUR (cf.
// hooks/useEnvoiFormulaire.ts). libelle, aide et erreur sont des CLÉS de
// traduction ; params, les paramètres de leurs messages ({min}...).
// =========================================================================

import { useTranslation } from 'react-i18next';

interface Props {
  idChamp: string; // l'id du <input>, relié au <label>
  libelle: string;
  valeur: string;
  onChange: (valeur: string) => void;
  erreur?: string;
  aide?: string;
  params?: Record<string, string>;
  type?: 'text' | 'password';
  longueurMax?: number;
  autoComplete?: string;
  requis?: boolean;
  autoFocus?: boolean;
  className?: string; // les classes du bloc (mb-3, col-sm-6...)
}

export default function ChampSaisie({
  idChamp, libelle, valeur, onChange, erreur, aide, params = {}, type = 'text', longueurMax,
  autoComplete = 'off', requis = false, autoFocus = false, className = '',
}: Props) {
  const { t } = useTranslation();
  const idMessage = `${idChamp}-message`;

  return (
    <div className={className}>
      <label className="form-label" htmlFor={idChamp}>{t(libelle)}</label>
      <input id={idChamp} type={type} className={`form-control ${erreur ? 'is-invalid' : ''}`} value={valeur}
             onChange={(e) => onChange(e.target.value)} maxLength={longueurMax} autoComplete={autoComplete}
             required={requis} autoFocus={autoFocus} aria-invalid={erreur ? true : undefined} aria-describedby={idMessage} />
      {erreur ? (
        <div className="invalid-feedback" id={idMessage}>{t(erreur, params)}</div>
      ) : (
        aide && <div className="form-text" id={idMessage}>{t(aide, params)}</div>
      )}
    </div>
  );
}

Un champ de formulaire complet : libellé, champ, message d'aide OU message d'erreur. Tous les formulaires de l'application l'utilisent.

  • lignes 18-32 : ses props ; [libelle], [aide] et [erreur] sont des CLÉS de traduction ;
  • lignes 34-37 : la déstructuration des props, avec des valeurs par défaut ([type = 'text']) ;
  • ligne 39 : l'identifiant du message d'aide ou d'erreur, que [aria-describedby] relie au champ : un lecteur d'écran le lit avec le champ ;
  • lignes 44-46 : un champ CONTRÔLÉ (exemple 05) : sa valeur appartient au formulaire parent, qui la reçoit par [onChange]. [is-invalid] : l'encadré rouge de [Bootstrap] ;
  • lignes 47-51 : l'erreur renvoyée par le serveur, sinon l'aide.

10.4.9.7. src/components/ChampsPersonne.tsx

// =========================================================================
// components/ChampsPersonne.tsx - titre, prénom, nom
// -------------------------------------------------------------------------
// Les champs d'identité, communs à trois formulaires : médecin / client de
// l'ADMIN, "Créer un compte client" et "Gérer mon compte" (l'équivalent du
// partiel views/partials/champsPersonne.hbs). Le parent lui passe les
// valeurs, les erreurs, et la fonction qui modifie un champ :
//     <ChampsPersonne valeurs={saisie} erreurs={envoi.erreurs} onChange={modifier} />
// =========================================================================

import { useTranslation } from 'react-i18next';
import type { Identite } from '../api/types';
import ChampSaisie from './ChampSaisie';

const TITRES = ['Mr', 'Mme', 'Melle'];

interface Props {
  valeurs: Identite;
  erreurs: Record<string, string>;
  onChange: (champ: keyof Identite, valeur: string) => void;
}

export default function ChampsPersonne({ valeurs, erreurs, onChange }: Props) {
  const { t } = useTranslation();
  return (
    <>
      <div className="mb-3">
        <label className="form-label" htmlFor="titre">{t('PERSONNES.TITLE_LABEL')}</label>
        <select id="titre" className={`form-select ${erreurs.titre ? 'is-invalid' : ''}`} value={valeurs.titre}
                onChange={(e) => onChange('titre', e.target.value)}>
          {TITRES.map((titre) => <option key={titre}>{titre}</option>)}
        </select>
        {erreurs.titre && <div className="invalid-feedback">{t(erreurs.titre)}</div>}
      </div>
      <div className="row g-3 mb-3">
        <ChampSaisie className="col-sm-6" idChamp="prenom" libelle="PERSONNES.FIRSTNAME_LABEL" longueurMax={20} requis
                     valeur={valeurs.prenom} onChange={(v) => onChange('prenom', v)} erreur={erreurs.prenom} />
        <ChampSaisie className="col-sm-6" idChamp="nom" libelle="PERSONNES.LASTNAME_LABEL" longueurMax={20} requis
                     valeur={valeurs.nom} onChange={(v) => onChange('nom', v)} erreur={erreurs.nom} />
      </div>
    </>
  );
}

Le titre, le prénom et le nom : les champs communs à trois formulaires (médecin ou client de l'ADMIN, création d'un compte, gestion de son compte). L'équivalent du partiel [views/partials/champsPersonne.hbs] de l'application HTML.

  • ligne 20 : [keyof Identite] : le type ['titre' | 'prenom' | 'nom'] ; le compilateur refuse tout autre nom de champ ;
  • ligne 31 : une [<option>] sans [value] a pour valeur son texte.

10.4.9.8. src/components/ChampCaptcha.tsx

// =========================================================================
// components/ChampCaptcha.tsx - l'image du captcha et le champ de saisie
// -------------------------------------------------------------------------
// Un composant d'affichage : l'image (svg), le lien "Autre image"
// (onRecharger), et le champ, CONTRÔLÉ par le formulaire parent (valeur,
// onChange). L'image et son identifiant viennent du hook useCaptcha, que
// le parent appelle (cf. pages/Connexion.tsx) : c'est lui qui en a besoin
// pour envoyer la réponse.
//
// dangerouslySetInnerHTML : l'image SVG est insérée telle quelle. Sans
// danger ICI parce qu'elle est fabriquée par notre serveur (bibliothèque
// svg-captcha) et ne contient rien qui vienne d'un utilisateur - cf.
// exemple 22.
// =========================================================================

import { useTranslation } from 'react-i18next';

interface Props {
  svg: string;
  valeur: string;
  onChange: (valeur: string) => void;
  onRecharger: () => void;
  erreur?: string;
}

export default function ChampCaptcha({ svg, valeur, onChange, onRecharger, erreur }: Props) {
  const { t } = useTranslation();
  return (
    <div className="mb-3">
      <label className="form-label" htmlFor="captcha">{t('CAPTCHA.LABEL')}</label>
      <div className="d-flex align-items-center gap-3 mb-2">
        <div className="captcha-image" role="img" aria-label={t('CAPTCHA.IMAGE_ALT')} dangerouslySetInnerHTML={{ __html: svg }} />
        <button type="button" className="btn btn-link btn-sm p-0" onClick={onRecharger}>{t('CAPTCHA.NEW_IMAGE')}</button>
      </div>
      <input id="captcha" type="text" maxLength={10} autoComplete="off" autoCapitalize="off" spellCheck={false}
             className={`form-control ${erreur ? 'is-invalid' : ''}`} value={valeur} onChange={(e) => onChange(e.target.value)} />
      {erreur ? <div className="invalid-feedback">{t(erreur)}</div> : <div className="form-text">{t('CAPTCHA.HELP')}</div>}
    </div>
  );
}

Un composant d'affichage : l'image, le bouton « Autre image », et le champ, contrôlé par le formulaire parent. L'image et son identifiant viennent du hook [useCaptcha], que la page appelle : c'est elle qui en a besoin pour envoyer la réponse.

  • ligne 32 : l'image SVG est insérée telle quelle (exemple 22). Sans danger ICI : elle est fabriquée par notre serveur et ne contient rien qui vienne d'un utilisateur. [role="img"] et [aria-label] : pour un lecteur d'écran, c'est une image, avec sa description ;
  • ligne 35 : [autoCapitalize="off"] et [spellCheck={false}] : ni majuscule automatique (téléphones), ni correcteur d'orthographe.

10.4.9.9. src/components/FenetreReservation.tsx

// =========================================================================
// components/FenetreReservation.tsx - la fenêtre de réservation d'un créneau
// -------------------------------------------------------------------------
// Même fenêtre que views/partials/reservation.hbs de l'application HTML :
// les classes Bootstrap d'une fenêtre modale, affichées "en dur" (show
// d-block).
//
// Le composant ne parle pas au serveur : il appelle
//   - onConfirmer, avec le patient choisi (null pour un patient qui réserve
//     pour lui-même) ;
//   - onFermer.
// C'est la page de l'agenda qui décide quoi en faire (pages/Agenda.tsx).
// =========================================================================

import { useEffect, useState, type FormEvent, type MouseEvent } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { Personne } from '../api/types';
import { libelle } from '../formatage';

interface Props {
  horaire: string; // "8:00 - 8:20"
  clients: readonly Personne[]; // ADMIN / DOCTOR : les patients parmi lesquels choisir
  patient: string | null; // USER : le patient, c'est lui-même
  envoiEnCours: boolean;
  onConfirmer: (idClient: number | null) => void;
  onFermer: () => void;
}

export default function FenetreReservation({ horaire, clients, patient, envoiEnCours, onConfirmer, onFermer }: Props) {
  const { t } = useTranslation();
  const [idClient, setIdClient] = useState<number | null>(null);

  // Échap, n'importe où dans la page : on ferme
  useEffect(() => {
    const clavier = (e: globalThis.KeyboardEvent) => {
      if (e.key === 'Escape') onFermer();
    };
    document.addEventListener('keydown', clavier);
    return () => document.removeEventListener('keydown', clavier);
  }, [onFermer]);

  function confirmer(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    onConfirmer(idClient);
  }

  // un clic sur le fond grisé (et non dans la fenêtre elle-même) ferme la fenêtre
  function clicFond(e: MouseEvent<HTMLDivElement>) {
    if (e.target === e.currentTarget) {
      onFermer();
    }
  }

  return createPortal(
    <>
      <div className="modal-backdrop fade show"></div>
      <div className="modal fade show d-block" tabIndex={-1} role="dialog" aria-modal="true" aria-labelledby="titre-reservation"
           onClick={clicFond}>
        <div className="modal-dialog modal-dialog-centered">
          <form className="modal-content" onSubmit={confirmer}>
            <div className="modal-header">
              <h5 id="titre-reservation" className="modal-title">{t('BOOKING_DIALOG.TITLE')}</h5>
              <button type="button" className="btn-close" aria-label={t('BOOKING_DIALOG.CLOSE_ARIA')} onClick={onFermer}></button>
            </div>

            <div className="modal-body">
              <p>{t('BOOKING_DIALOG.SLOT', { horaire })}</p>
              {/* un patient réserve pour lui-même : pas de liste (et le serveur
                  ignorerait de toute façon un éventuel idClient) */}
              {patient !== null ? (
                <p className="mb-0">{t('BOOKING_DIALOG.PATIENT_LABEL')} : <strong>{patient}</strong></p>
              ) : (
                <>
                  <label className="form-label" htmlFor="idClient">{t('BOOKING_DIALOG.PATIENT_LABEL')}</label>
                  {/* autoFocus : à l'affichage, le focus sur la liste */}
                  <select id="idClient" className="form-select" autoFocus value={idClient ?? ''}
                          onChange={(e) => setIdClient(e.target.value === '' ? null : Number(e.target.value))}>
                    <option value="">{t('BOOKING_DIALOG.CHOOSE_PATIENT')}</option>
                    {clients.map((c) => <option key={c.id} value={c.id}>{libelle(c)}</option>)}
                  </select>
                </>
              )}
            </div>

            <div className="modal-footer">
              <button type="button" className="btn btn-secondary" onClick={onFermer}>{t('BOOKING_DIALOG.CANCEL')}</button>
              {/* sans liste, c'est le bouton de confirmation qui a le focus */}
              <button type="submit" className="btn btn-primary" autoFocus={patient !== null} disabled={envoiEnCours}>
                {t('BOOKING_DIALOG.CONFIRM')}
              </button>
            </div>
          </form>
        </div>
      </div>
    </>,
    document.body,
  );
}

La fenêtre de réservation d'un créneau. Elle ne parle pas au serveur : elle appelle [onConfirmer] avec le patient choisi, ou [onFermer] ; c'est la page de l'agenda qui décide quoi en faire.

  • lignes 21-28 : [clients] : la liste des patients, pour l'ADMIN et les médecins ; [patient] : le nom du patient qui réserve pour lui-même ([null] pour l'ADMIN et les médecins) ;
  • ligne 32 : le patient choisi dans la liste ;
  • lignes 35-41 : Échap, n'importe où dans la page, ferme la fenêtre. L'effet s'abonne à l'événement [keydown] du document, et son nettoyage se désabonne à la fermeture. [globalThis.KeyboardEvent] : l'événement du navigateur, et non celui de [React], qui porte le même nom ;
  • lignes 43-46 : [preventDefault()] empêche l'envoi du formulaire par le navigateur ;
  • lignes 71-83 : un patient réserve pour lui-même : pas de liste. Le serveur ignorerait de toute façon un éventuel [idClient] ;
  • ligne 78 : la valeur d'une [<option>] est toujours une chaîne : on la convertit en nombre ;
  • ligne 89 : sans liste, c'est le bouton de confirmation qui reçoit le focus.

10.4.10. Les pages

10.4.10.1. src/pages/Connexion.tsx

// =========================================================================
// pages/Connexion.tsx - la page de connexion (/login)
// -------------------------------------------------------------------------
// Même page que views/login.hbs de l'application HTML : login, mot de
// passe, captcha, et un second bouton "Créer un compte client" qui vérifie
// le MÊME captcha avant d'ouvrir la création de compte.
//
// /login?login=jbernard : le champ login est pré-rempli (juste après la
// création d'un compte).
// /login?redirect=/medecins : après la connexion, on ouvre cette page
// (déposé par le garde du routeur) plutôt que la page d'accueil du rôle.
// =========================================================================

import { useState, type FormEvent } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import { api } from '../api/rdvmedecins.api';
import { pageAccueil, useSession } from '../stores/session';
import { useMessages } from '../stores/messages';
import { useEnvoiFormulaire } from '../hooks/useEnvoiFormulaire';
import { useCaptcha } from '../hooks/useCaptcha';
import ChampCaptcha from '../components/ChampCaptcha';
import TitrePage from '../components/TitrePage';

export default function Connexion() {
  const { t } = useTranslation();
  const [parametres] = useSearchParams();
  const navigate = useNavigate();
  const connecter = useSession((s) => s.connecter);
  const effacerMessages = useMessages((m) => m.effacer);
  const envoi = useEnvoiFormulaire();
  const captcha = useCaptcha();

  // le login de l'URL, s'il y en a un : la valeur de DÉPART du champ
  const loginUrl = (parametres.get('login') ?? '').slice(0, 30);
  const [saisie, setSaisie] = useState({ login: loginUrl, password: '', captcha: '' });

  function modifier(champ: keyof typeof saisie, valeur: string) {
    setSaisie((s) => ({ ...s, [champ]: valeur }));
    envoi.effacerErreur(champ);
  }

  // les deux boutons partagent ce déroulement : envoi, puis, en cas d'échec,
  // message d'erreur et NOUVELLE image (chacune ne sert qu'une fois)
  async function tenter(action: () => Promise<unknown>): Promise<void> {
    effacerMessages();
    const reussi = await envoi.envoyer(action);
    if (!reussi) {
      setSaisie((s) => ({ ...s, captcha: '' }));
      await captcha.recharger();
    }
  }

  async function seConnecter(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    await tenter(async () => {
      await connecter(saisie.login.trim(), saisie.password, captcha.captchaId, saisie.captcha);
      const redirect = parametres.get('redirect');
      // seulement un chemin INTERNE : jamais de redirection vers un autre site
      const interne = redirect !== null && redirect.startsWith('/') && !redirect.startsWith('//');
      await navigate(interne ? redirect : pageAccueil(useSession.getState().utilisateur));
    });
  }

  async function creerCompte() {
    await tenter(async () => {
      await api.accesInscription(captcha.captchaId, saisie.captcha);
      await navigate('/inscription');
    });
  }

  return (
    <div className="row justify-content-center">
      <TitrePage cle="LOGIN.TITLE" />
      <div className="col-sm-8 col-md-6 col-lg-5">
        <div className="card p-4">
          <h5 className="card-title mb-3">{t('LOGIN.TITLE')}</h5>

          <form onSubmit={seConnecter}>
            <div className="mb-3">
              <label className="form-label" htmlFor="login">{t('LOGIN.LOGIN_LABEL')}</label>
              {/* required : le navigateur refuse d'envoyer le formulaire tant que le champ est vide.
                  autoFocus : le focus sur le login... ou sur le mot de passe si le login est déjà rempli */}
              <input id="login" type="text" className="form-control" autoComplete="username" required
                     autoFocus={loginUrl === ''} value={saisie.login} onChange={(e) => modifier('login', e.target.value)} />
            </div>
            <div className="mb-3">
              <label className="form-label" htmlFor="password">{t('LOGIN.PASSWORD_LABEL')}</label>
              <input id="password" type="password" className="form-control" autoComplete="current-password" required
                     autoFocus={loginUrl !== ''} value={saisie.password} onChange={(e) => modifier('password', e.target.value)} />
            </div>

            <ChampCaptcha svg={captcha.svg} valeur={saisie.captcha} onChange={(v) => modifier('captcha', v)}
                          onRecharger={captcha.recharger} erreur={envoi.erreurs.captcha} />

            {/* type="submit" : Entrée dans un champ déclenche "Se connecter" */}
            <button type="submit" className="btn btn-primary w-100" disabled={envoi.enCours}>{t('LOGIN.SUBMIT')}</button>

            {/* type="button" : ce bouton n'envoie pas le formulaire (et ignore
                donc les champs required, inutiles ici) */}
            <button type="button" className="btn btn-outline-primary w-100 mt-3" disabled={envoi.enCours} onClick={creerCompte}>
              {t('LOGIN.CREATE_ACCOUNT')}
            </button>
            <div className="form-text text-center">{t('LOGIN.CREATE_ACCOUNT_HELP')}</div>
          </form>

          <div className="text-muted small mt-3">
            {t('LOGIN.DEMO_ACCOUNTS')}
            <ul className="mb-0 mt-1">
              <li><code>admin</code> - {t('LOGIN.DEMO_ADMIN')}</li>
              <li><code>pelissier</code>, <code>tatou</code>, <code>dupont</code> - {t('LOGIN.DEMO_DOCTOR')}</li>
              <li><code>martin</code>, <code>durand</code>, <code>bistrou</code>, <code>german</code> - {t('LOGIN.DEMO_USER')}</li>
            </ul>
          </div>
        </div>
      </div>
    </div>
  );
}

La page de [views/login.hbs] de l'application HTML : login, mot de passe, captcha, et un second bouton « Créer un compte client » qui vérifie le MÊME captcha avant d'ouvrir la création de compte.

  • lignes 29-32 : ce dont la page a besoin : la connexion (magasin de session), l'effacement des messages, l'envoi du formulaire et le captcha (nos deux hooks) ;
  • lignes 35-36 : [/login?login=jbernard] : juste après la création d'un compte, le login est pré-rempli. [slice(0, 30)] : on ne reprend pas plus que la longueur maximale d'un login. L'URL ne donne que la valeur de DÉPART du champ ;
  • lignes 38-41 : la saisie d'un champ : sa nouvelle valeur, et son erreur disparaît. [[champ]: valeur] : une propriété dont le nom est calculé ;
  • lignes 45-52 : les deux boutons partagent le même déroulement : envoi, puis, en cas d'échec, on vide le captcha et on charge une NOUVELLE image : chacune ne sert qu'une fois ;
  • lignes 54-63 : la connexion, puis la page suivante :
    • ligne 58 : la page demandée avant la connexion, retenue par le garde du routeur ([/login?redirect=/medecins]) ;
    • ligne 60 : SEULEMENT un chemin interne : [/medecins], mais pas [https://site-pirate.com] ni [//site-pirate.com] (une adresse « sans protocole », qui désigne un autre site). Sinon, un lien piégé [/login?redirect=https://...] enverrait l'utilisateur, juste connecté, sur un autre site : c'est la faille dite de la « redirection ouverte » ;
    • ligne 61 : sinon, la page d'accueil du rôle. [useSession.getState()] : l'utilisateur QUI VIENT d'être rangé dans le magasin ; la variable d'un composant, elle, ne changera qu'au prochain affichage ;
  • lignes 65-70 : le second bouton : le captcha est vérifié par le serveur, qui donne en échange un laissez-passer (un cookie) pour la page [/inscription] ;
  • lignes 84-85 : [required] : le navigateur refuse d'envoyer le formulaire tant que le champ est vide. C'est la seule vérification faite dans le navigateur, un simple confort. [autoFocus] : le focus sur le login, ou sur le mot de passe si le login est déjà rempli (ligne 90) ;
  • ligne 97 : [type="submit"] : Entrée dans un champ déclenche « Se connecter » ;
  • ligne 101 : [type="button"] : ce bouton n'envoie pas le formulaire, et ignore donc les champs [required], inutiles ici.

10.4.10.2. src/pages/Agenda.tsx

La page la plus riche de l'application. Comme dans l'application HTML, TOUT son état est dans son URL :

/agenda?idMedecin=1&jour=2026-10-05              l'agenda
/agenda?idMedecin=1&jour=2026-10-05&reserver=7   + la fenêtre de réservation

La page SUIT son URL : elle lit ses paramètres et en déduit ce qu'elle affiche ; les actions de l'utilisateur, elles, changent l'URL. On peut ainsi recharger la page, la mettre en favori, utiliser les boutons Précédent / Suivant du navigateur.

// =========================================================================
// pages/Agenda.tsx - la page de l'agenda (/agenda)
// -------------------------------------------------------------------------
// Trois blocs, comme views/agenda.hbs de l'application HTML :
//   1. le choix du médecin et du jour ;
//   2. l'agenda de ce médecin ce jour-là ;
//   3. la fenêtre de réservation d'un créneau.
//
// Comme dans l'application HTML, TOUT l'état de la page est dans son URL :
//     /agenda?idMedecin=1&jour=2026-10-05             l'agenda
//     /agenda?idMedecin=1&jour=2026-10-05&reserver=7  + la fenêtre de réservation
// On peut donc recharger la page, la mettre en favori, utiliser les boutons
// Précédent / Suivant du navigateur. La page SUIT son URL : elle lit ses
// trois paramètres (useSearchParams) et en déduit ce qu'elle affiche ;
// l'agenda est une requête TanStack Query dont la clé contient le médecin
// et le jour (cf. exemple 24) ; les actions de l'utilisateur, elles,
// changent l'URL (navigate).
//
// Ce que chacun voit et peut faire est décidé par le SERVEUR (champs
// patient, moi, reservable, idRv de chaque créneau) : la page se contente de
// l'afficher. Un patient ne reçoit même pas le nom des autres patients.
// =========================================================================

import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ErreurApi } from '../api/erreur-api';
import { api } from '../api/rdvmedecins.api';
import type { CreneauAgenda, Personne } from '../api/types';
import { aujourdhui, estUneDateValide, horaire, jourLong, libelle } from '../formatage';
import { estGestionnaire, estPatient, useSession } from '../stores/session';
import { useMessages } from '../stores/messages';
import { confirmer } from '../stores/confirmation';
import FenetreReservation from '../components/FenetreReservation';
import TitrePage from '../components/TitrePage';

// "3" -> 3 ; "", "abc", "-2", absent (null) -> null
function entierPositif(valeur: string | null): number | null {
  const n = Number(valeur);
  return valeur !== null && valeur !== '' && Number.isInteger(n) && n > 0 ? n : null;
}

// l'adresse de l'agenda (sans fenêtre de réservation, ou avec)
function adresseAgenda(idMedecin: number | null, jour: string, reserver?: number): string {
  const parametres = new URLSearchParams({ idMedecin: String(idMedecin ?? ''), jour });
  if (reserver !== undefined) {
    parametres.set('reserver', String(reserver));
  }
  return `/agenda?${parametres}`;
}

// -------------------------------------------------------------------------
// 1. le choix du médecin et du jour : un petit formulaire, dont l'état ne
// sert qu'avant l'envoi. Il DÉMARRE avec les valeurs de l'URL ; la page lui
// donne une clé (key) qui change avec elles : React le recrée alors, et il
// repart des nouvelles valeurs (bouton Précédent...).
// -------------------------------------------------------------------------
interface PropsChoix {
  medecins: Personne[];
  idMedecin: number | null;
  jour: string;
  onVoir: (idMedecin: number | null, jour: string) => void;
}

function ChoixMedecinJour({ medecins, idMedecin, jour, onVoir }: PropsChoix) {
  const { t } = useTranslation();
  const [choixMedecin, setChoixMedecin] = useState(idMedecin);
  const [choixJour, setChoixJour] = useState(jour);

  function voir(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    onVoir(choixMedecin, choixJour);
  }

  return (
    <form className="card p-3 mb-3" onSubmit={voir}>
      <div className="row g-2 align-items-end">
        <div className="col-sm-5">
          <label className="form-label" htmlFor="idMedecin">{t('DOCTOR_DAY_PICKER.DOCTOR_LABEL')}</label>
          <select id="idMedecin" className="form-select" required value={choixMedecin ?? ''}
                  onChange={(e) => setChoixMedecin(entierPositif(e.target.value))}>
            <option value="">{t('DOCTOR_DAY_PICKER.CHOOSE_DOCTOR')}</option>
            {medecins.map((m) => <option key={m.id} value={m.id}>{libelle(m)}</option>)}
          </select>
        </div>
        <div className="col-sm-4">
          <label className="form-label" htmlFor="jour">{t('DOCTOR_DAY_PICKER.DAY_LABEL')}</label>
          <input id="jour" type="date" className="form-control" required value={choixJour}
                 onChange={(e) => setChoixJour(e.target.value)} />
        </div>
        <div className="col-sm-3">
          <button type="submit" className="btn btn-primary w-100">{t('DOCTOR_DAY_PICKER.VIEW_AGENDA')}</button>
        </div>
      </div>
    </form>
  );
}

// -------------------------------------------------------------------------
// la page
// -------------------------------------------------------------------------
export default function Agenda() {
  const { t, i18n } = useTranslation();
  const navigate = useNavigate();
  const queryClient = useQueryClient();
  const utilisateur = useSession((s) => s.utilisateur);
  // les actions du magasin des messages : des fonctions qui ne changent
  // jamais ; getState() les donne sans abonner la page aux messages
  const { afficherSucces, afficherErreur, afficherErreurApi, flash } = useMessages.getState();
  const [envoiEnCours, setEnvoiEnCours] = useState(false);

  // les paramètres de l'URL
  const [parametres] = useSearchParams();
  const idMedecinUrl = entierPositif(parametres.get('idMedecin'));
  const jourUrl = parametres.get('jour');
  const idReserver = entierPositif(parametres.get('reserver'));
  // le jour : celui de l'URL s'il est valide, sinon aujourd'hui
  const jourInvalide = jourUrl !== null && !estUneDateValide(jourUrl);
  const jour = jourUrl !== null && !jourInvalide ? jourUrl : aujourdhui();

  // les listes : les médecins pour tous ; les patients seulement pour ceux
  // qui ont le droit de les lire (ADMIN, DOCTOR : fenêtre de réservation)
  const medecins = useQuery({ queryKey: ['medecins'], queryFn: () => api.lister('medecins') });
  const clients = useQuery({
    queryKey: ['clients'],
    queryFn: () => api.lister('clients'),
    enabled: estGestionnaire(utilisateur),
  });

  // l'agenda : sans médecin dans l'URL, un DOCTOR voit SON agenda (le
  // serveur le retrouve à partir du jeton) ; les autres ne voient rien
  const avecAgenda = !jourInvalide && (idMedecinUrl !== null || utilisateur?.role === 'DOCTOR');
  const agenda = useQuery({
    queryKey: ['agenda', idMedecinUrl, jour],
    queryFn: () => api.agenda(idMedecinUrl, jour),
    enabled: avecAgenda,
  });
  const a = avecAgenda ? agenda.data : undefined;
  // le médecin affiché : celui de l'URL, ou celui de l'agenda reçu (DOCTOR)
  const idMedecin = idMedecinUrl ?? a?.medecin.id ?? null;

  // une date invalide dans l'URL : un message (un EFFET : on agit sur le
  // magasin des messages, pas sur l'affichage de ce composant)
  useEffect(() => {
    if (jourInvalide) {
      afficherErreur('ERRORS.INVALID_DATE');
    }
  }, [jourInvalide, afficherErreur]);

  // le créneau dont la fenêtre de réservation est ouverte (?reserver=7),
  // seulement s'il est réservable par cet utilisateur
  const creneauAReserver = a?.creneaux.find((c) => c.id === idReserver && c.reservable) ?? null;
  // le nom du patient connecté (USER), ou null pour ADMIN et DOCTOR
  const patient = estPatient(utilisateur) ? (utilisateur?.nom ?? '') : null;

  // -----------------------------------------------------------------------
  // actions
  // -----------------------------------------------------------------------

  // "Voir l'agenda" : on change l'URL, la page suit
  function voirAgenda(idMedecinChoisi: number | null, jourChoisi: string) {
    navigate(adresseAgenda(idMedecinChoisi, jourChoisi));
  }

  // useCallback : la MÊME fonction d'un affichage à l'autre (la fenêtre de
  // réservation l'utilise dans les dépendances d'un effet)
  const fermerReservation = useCallback(() => navigate(adresseAgenda(idMedecin, jour)), [navigate, idMedecin, jour]);

  // c'est le serveur qui fait foi : après chaque action, on relit l'agenda
  // (et l'espace du patient, qui montre son rendez-vous)
  const recharger = () =>
    Promise.all([queryClient.invalidateQueries({ queryKey: ['agenda'] }), queryClient.invalidateQueries({ queryKey: ['mon-espace'] })]);

  async function confirmerReservation(idClient: number | null) {
    if (creneauAReserver === null) {
      return;
    }
    setEnvoiEnCours(true);
    try {
      await api.reserver(jour, creneauAReserver.id, estGestionnaire(utilisateur) ? idClient : null);
      flash('succes', 'AGENDA.BOOKED');
    } catch (e) {
      if (e instanceof ErreurApi && e.cle === 'ERRORS.CHOOSE_PATIENT') {
        // aucun patient choisi : la fenêtre reste ouverte
        afficherErreur(e.cle);
        return; // (finally s'exécute quand même)
      }
      if (!(e instanceof ErreurApi)) {
        throw e;
      }
      // créneau pris entre-temps, patient qui a déjà un rendez-vous... :
      // le message s'affichera sur l'agenda, fenêtre fermée
      flash('erreur', e.cle, e.params);
    } finally {
      setEnvoiEnCours(false);
    }
    await fermerReservation();
    await recharger();
  }

  async function annuler(c: CreneauAgenda) {
    if (c.idRv === null || !(await confirmer('AGENDA.CANCEL_CONFIRM'))) {
      return;
    }
    try {
      await api.annulerRv(c.idRv);
      afficherSucces('AGENDA.CANCELLED');
    } catch (e) {
      afficherErreurApi(e);
    }
    await recharger();
  }

  // -----------------------------------------------------------------------
  // affichage
  // -----------------------------------------------------------------------
  let contenu;
  if (agenda.isLoading && !a) {
    contenu = <p className="text-muted">{t('APP.LOADING')}</p>;
  } else if (a) {
    contenu = (
      <div className="card p-3">
        <h5>{t('AGENDA.TITLE_PREFIX')} {jourLong(a.jour, i18n.language)} - {libelle(a.medecin)}</h5>
        {a.creneaux.length > 0 ? (
          <table className="table table-hover align-middle">
            <thead>
              <tr>
                <th>{t('AGENDA.COLUMN_SLOT')}</th>
                <th>{t('AGENDA.COLUMN_STATUS')}</th>
                <th></th>
              </tr>
            </thead>
            <tbody>
              {a.creneaux.map((c) => (
                <tr key={c.id} className={c.reservable ? 'creneau-libre' : ''}>
                  <td>{horaire(c)}</td>
                  <td>
                    {c.libre ? (
                      <span className="badge text-bg-success">{t('AGENDA.FREE')}</span>
                    ) : c.patient ? (
                      <span className={`badge ${c.moi ? 'text-bg-primary' : 'text-bg-secondary'}`}>{libelle(c.patient)}</span>
                    ) : (
                      <span className="badge text-bg-secondary">{t('AGENDA.BOOKED_BY_OTHER')}</span>
                    )}
                  </td>
                  <td className="text-end">
                    {/* "Réserver" est un LIEN : il ajoute &reserver=... à l'URL, ce
                        qui ouvre la fenêtre de réservation */}
                    {c.reservable && (
                      <Link className="btn btn-sm btn-outline-success" to={adresseAgenda(idMedecin, jour, c.id)}>
                        {t('AGENDA.BOOK')}
                      </Link>
                    )}
                    {c.idRv !== null && (
                      <button type="button" className="btn btn-sm btn-outline-danger" onClick={() => annuler(c)}>
                        {t('AGENDA.CANCEL_APPOINTMENT')}
                      </button>
                    )}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        ) : (
          <p className="text-muted mb-0">{t('AGENDA.NO_SLOTS')}</p>
        )}
      </div>
    );
  } else {
    contenu = <p className="text-muted">{t('AGENDA.EMPTY_STATE')}</p>;
  }

  return (
    <>
      <TitrePage cle="AGENDA.PAGE_TITLE" />
      <ChoixMedecinJour key={`${idMedecin}|${jour}`} medecins={medecins.data ?? []} idMedecin={idMedecin} jour={jour}
                        onVoir={voirAgenda} />

      {/* patient qui a déjà un rendez-vous à venir : pourquoi il n'y a pas de bouton "Réserver" */}
      {a?.info && (
        <div className="alert alert-info">
          {t(a.info)} <Link to="/mon-espace" className="alert-link">{t('APP.MENU_MY_SPACE')}</Link>
        </div>
      )}

      {contenu}

      {/* 3. la fenêtre de réservation, seulement si l'URL contient &reserver=... */}
      {creneauAReserver && (
        <FenetreReservation horaire={horaire(creneauAReserver)} clients={clients.data ?? []} patient={patient}
                            envoiEnCours={envoiEnCours} onConfirmer={confirmerReservation} onFermer={fermerReservation} />
      )}
    </>
  );
}
  • lignes 39-42 : un paramètre de l'URL est une chaîne, tapée par n'importe qui : on n'en retient qu'un entier positif ;
  • lignes 45-51 : l'adresse de l'agenda, avec ou sans fenêtre de réservation ;
  • lignes 66-98 : le formulaire de choix du médecin et du jour, un composant du même fichier. Son état ne sert qu'avant l'envoi : il DÉMARRE avec les valeurs de l'URL (lignes 68-69). Mais [useState(valeur)] n'utilise sa valeur qu'au PREMIER affichage : si l'URL change (bouton Précédent), le formulaire garderait l'ancien choix. D'où la clé que lui donne la page, plus bas ;
  • lignes 114-120 : les paramètres de l'URL. Le jour : celui de l'URL s'il est valide, sinon aujourd'hui ;
  • lignes 124-129 : les listes, avec [TanStack Query] (exemple 24). La clé [['medecins']] désigne les données dans le cache : une autre page qui demande la même clé les reçoit sans nouvelle requête. [enabled] : les patients, seulement pour ceux qui ont le droit de les lire (l'ADMIN et les médecins, pour la fenêtre de réservation) ; pour un patient, la requête n'est pas envoyée ;
  • lignes 133-138 : l'agenda. Sa clé contient le médecin et le jour : quand l'URL change, la clé change, et [TanStack Query] envoie la nouvelle requête tout seul. Sans médecin dans l'URL, un médecin connecté voit SON agenda ; les autres ne voient rien ;
  • ligne 141 : le médecin affiché : celui de l'URL, sinon celui de l'agenda reçu ;
  • lignes 145-149 : une date invalide dans l'URL : un message. C'est un EFFET : on agit sur le magasin des messages, pas sur l'affichage de ce composant. On ne modifie jamais un autre état pendant l'affichage ;
  • ligne 153 : le créneau dont la fenêtre de réservation est ouverte ([?reserver=7]), seulement s'il est réservable par cet utilisateur : une URL fabriquée à la main n'ouvre rien ;
  • lignes 162-164 : « Voir l'agenda » change l'URL ; la page suit ;
  • ligne 168 : fermer la fenêtre, c'est revenir à l'adresse de l'agenda. [useCallback] : la MÊME fonction d'un affichage à l'autre, car la fenêtre de réservation l'utilise dans les dépendances d'un effet (cf. [FenetreReservation.tsx]) : une fonction nouvelle à chaque affichage relancerait l'effet à chaque fois ;
  • lignes 172-173 : c'est le serveur qui fait foi : après chaque action, on INVALIDE l'agenda (et l'espace du patient, qui montre son rendez-vous). [TanStack Query] relit aussitôt les données affichées ; celles qui ne le sont pas seront relues à leur prochaine utilisation ;
  • lignes 175-200 : la réservation :
    • ligne 181 : l'ADMIN et les médecins envoient le patient choisi ; un patient réserve pour lui-même ;
    • ligne 182 : le message s'affichera après la navigation qui ferme la fenêtre ;
    • lignes 184-188 : aucun patient choisi : la fenêtre reste ouverte, avec le message. [return] dans un [try] : le bloc [finally] s'exécute quand même ;
    • ligne 194 : les autres erreurs (créneau pris entre-temps, patient qui a déjà un rendez-vous...) s'afficheront sur l'agenda, fenêtre fermée ;
  • lignes 202-213 : l'annulation, après confirmation : [await confirmer(...)] attend la réponse de l'utilisateur (cf. [stores/confirmation.ts]) ;
  • lignes 218-272 : le contenu principal, calculé avant le JSX : un message d'attente, l'agenda, ou l'invitation à choisir un médecin. Un [if / else] ordinaire est souvent plus lisible qu'une cascade de [? :] dans le JSX ;
  • lignes 239-245 : le statut du créneau : libre, le nom du patient (en bleu si c'est le patient connecté), ou « Réservé » : un patient ne reçoit PAS le nom des autres patients, le serveur ne l'envoie pas ;
  • lignes 250-254 : « Réserver » est un LIEN, [<Link>] : il ajoute [&reserver=...] à l'URL, ce qui ouvre la fenêtre de réservation ;
  • ligne 255 : « Annuler », si le serveur a dit que l'utilisateur peut annuler ce rendez-vous ;
  • ligne 277 : la clé du formulaire contient le médecin et le jour. Quand ils changent, la clé change : [React] détruit le formulaire et en crée un nouveau, qui démarre avec les nouvelles valeurs. C'est la façon [React] de « remettre à zéro » un composant ;
  • lignes 281-285 : un patient qui a déjà un rendez-vous à venir : pourquoi il n'y a pas de bouton « Réserver » ;
  • lignes 290-293 : la fenêtre de réservation, seulement si l'URL contient [&reserver=...].

10.4.10.3. src/pages/personnes.config.ts

// =========================================================================
// pages/personnes.config.ts - médecins ou clients ?
// -------------------------------------------------------------------------
// Les pages de gestion des médecins et des clients sont IDENTIQUES : même
// liste, même formulaire. Comme dans l'application HTML (classe abstraite
// PersonnesController), on les écrit UNE fois (Personnes.tsx,
// PersonneFormulaire.tsx) ; seules changent l'adresse de l'API et les
// clés des libellés, décrites ici. Le routeur passe le type à chaque page
// (cf. routes.tsx).
// =========================================================================

import type { TypePersonnes } from '../api/rdvmedecins.api';

export interface ClesPersonnes {
  titre: string; // "Médecins"
  ajouter: string; // "Ajouter un médecin"
  modifier: string; // "Modifier un médecin"
  vide: string; // "Aucun médecin."
  confirmerSuppression: string;
  ajoute: string; // "Le médecin {nom} a été ajouté (login : {login})."
  modifie: string;
  supprime: string;
}

export const CLES_PERSONNES: Record<TypePersonnes, ClesPersonnes> = {
  medecins: {
    titre: 'MEDECINS.TITLE',
    ajouter: 'MEDECINS.ADD',
    modifier: 'MEDECINS.EDIT',
    vide: 'MEDECINS.EMPTY',
    confirmerSuppression: 'MEDECINS.DELETE_CONFIRM',
    ajoute: 'MEDECINS.ADDED',
    modifie: 'MEDECINS.UPDATED',
    supprime: 'MEDECINS.DELETED',
  },
  clients: {
    titre: 'CLIENTS.TITLE',
    ajouter: 'CLIENTS.ADD',
    modifier: 'CLIENTS.EDIT',
    vide: 'CLIENTS.EMPTY',
    confirmerSuppression: 'CLIENTS.DELETE_CONFIRM',
    ajoute: 'CLIENTS.ADDED',
    modifie: 'CLIENTS.UPDATED',
    supprime: 'CLIENTS.DELETED',
  },
};

// {nom} et {login} des messages "ajouté / modifié / supprimé"
export function parametres(p: { prenom: string; nom: string; login?: string }): Record<string, string> {
  return { nom: `${p.prenom} ${p.nom}`, login: p.login ?? '' };
}

Les pages de gestion des médecins et des clients sont IDENTIQUES : même liste, même formulaire. Comme l'application HTML (classe abstraite [PersonnesController]), on les écrit UNE fois ; seules changent l'adresse de l'API (le type, ['medecins'] ou ['clients']) et les clés des libellés, décrites ici.

  • lignes 14-23 : les clés de traduction propres à chaque type ;
  • ligne 25 : [Record<TypePersonnes, ClesPersonnes>] : un objet qui a exactement une propriété par type, [medecins] et [clients] : le compilateur signale un oubli ;
  • lignes 49-51 : les paramètres [{{nom}}] et [{{login}}] des messages « ajouté / modifié / supprimé ».

10.4.10.4. src/pages/Personnes.tsx

// =========================================================================
// pages/Personnes.tsx - la liste des médecins OU des clients (ADMIN)
// -------------------------------------------------------------------------
// Une seule page pour les deux : le type ('medecins' ou 'clients') arrive
// en prop (cf. routes.tsx) ; les libellés propres à chacun sont dans
// personnes.config.ts. Équivalent de views/personnes/liste.hbs de
// l'application HTML.
// =========================================================================

import { Link } from 'react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api, type TypePersonnes } from '../api/rdvmedecins.api';
import type { Personne } from '../api/types';
import { useMessages } from '../stores/messages';
import { confirmer } from '../stores/confirmation';
import TitrePage from '../components/TitrePage';
import { CLES_PERSONNES, parametres } from './personnes.config';

export default function Personnes({ type }: { type: TypePersonnes }) {
  const { t } = useTranslation();
  const queryClient = useQueryClient();
  const cles = CLES_PERSONNES[type];
  // la liste ; la clé contient le type : médecins et clients ont chacun la leur
  const personnes = useQuery({ queryKey: [type], queryFn: () => api.lister(type) });

  async function supprimer(p: Personne) {
    if (!(await confirmer(cles.confirmerSuppression))) {
      return;
    }
    const messages = useMessages.getState();
    try {
      await api.supprimer(type, p.id);
      messages.afficherSucces(cles.supprime, parametres(p));
    } catch (e) {
      // un médecin qui a encore des rendez-vous : 409 ERRORS.DOCTOR_HAS_APPOINTMENTS
      messages.afficherErreurApi(e);
    }
    await queryClient.invalidateQueries({ queryKey: [type] });
  }

  const liste = personnes.data;
  let contenu;
  if (liste === undefined) {
    // pas encore chargée (ou chargement en échec : le message est en bas de page)
    contenu = <p className="text-muted mb-0">{t('APP.LOADING')}</p>;
  } else if (liste.length === 0) {
    contenu = <p className="text-muted mb-0">{t(cles.vide)}</p>;
  } else {
    contenu = (
      <table className="table table-hover align-middle mb-0">
        <thead>
          <tr>
            <th>{t('PERSONNES.COLUMN_TITLE')}</th>
            <th>{t('PERSONNES.COLUMN_FIRSTNAME')}</th>
            <th>{t('PERSONNES.COLUMN_LASTNAME')}</th>
            <th>{t('PERSONNES.COLUMN_LOGIN')}</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {liste.map((p) => (
            <tr key={p.id}>
              <td>{p.titre}</td>
              <td>{p.prenom}</td>
              <td>{p.nom}</td>
              <td><code>{p.login}</code></td>
              <td className="text-end">
                <div className="d-inline-flex gap-2">
                  <Link className="btn btn-sm btn-outline-primary" to={`/${type}/${p.id}/modifier`}>{t('PERSONNES.EDIT')}</Link>
                  <button type="button" className="btn btn-sm btn-outline-danger" onClick={() => supprimer(p)}>
                    {t('PERSONNES.DELETE')}
                  </button>
                </div>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    );
  }

  return (
    <>
      <TitrePage cle={cles.titre} />
      <div className="d-flex justify-content-between align-items-center mb-3">
        <h4 className="mb-0">{t(cles.titre)}</h4>
        <Link className="btn btn-primary" to={`/${type}/ajouter`}>{t(cles.ajouter)}</Link>
      </div>
      <div className="card p-3">{contenu}</div>
    </>
  );
}

La liste des médecins ou des clients. Le type arrive en prop (cf. [routes.tsx]).

  • ligne 25 : la liste. Sa clé est le type : médecins et clients ont chacun la leur. La liste des médecins est la MÊME donnée que celle de l'agenda (clé [['medecins']]) : l'une comme l'autre profitent du cache ;
  • lignes 27-40 : la suppression, après confirmation, puis on relit la liste. Un médecin qui a encore des rendez-vous ne peut pas être supprimé : le serveur répond 409, et son message s'affiche en bas de la page ;
  • ligne 31 : [getState()] donne les fonctions du magasin sans abonner la page aux messages : elle n'a pas à être ré-affichée quand un message change ;
  • lignes 44-81 : le contenu : « Chargement... », « Aucun médecin. », ou le tableau ;
  • ligne 70 : « Modifier » est un lien vers la page du formulaire.

10.4.10.5. src/pages/PersonneFormulaire.tsx

// =========================================================================
// pages/PersonneFormulaire.tsx - création OU modification d'un médecin ou
// d'un client (ADMIN)
// -------------------------------------------------------------------------
//     /medecins/ajouter          -> création (pas de paramètre id)
//     /medecins/3/modifier       -> modification (paramètre id = "3")
// (et les mêmes adresses sous /clients). Équivalent de
// views/personnes/formulaire.hbs de l'application HTML.
//
// Deux composants :
//   - PersonneFormulaire lit la fiche à modifier (s'il y en a une) ;
//   - Formulaire n'est affiché qu'une fois la fiche lue : il DÉMARRE avec
//     ses valeurs (état initial), sans avoir à les recopier plus tard.
// Aucune règle n'est vérifiée ici : le serveur valide tout, et ses erreurs
// s'affichent sous les champs (cf. hooks/useEnvoiFormulaire.ts).
// La VERSION lue avec la fiche repart avec la modification : si un autre
// ADMIN l'a modifiée entre-temps, le serveur répond 409 CONCURRENT_UPDATE.
// =========================================================================

import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ErreurApi } from '../api/erreur-api';
import { api, type TypePersonnes } from '../api/rdvmedecins.api';
import type { DonneesPersonne } from '../api/types';
import { useMessages } from '../stores/messages';
import { useEnvoiFormulaire } from '../hooks/useEnvoiFormulaire';
import ChampsPersonne from '../components/ChampsPersonne';
import ChampSaisie from '../components/ChampSaisie';
import TitrePage from '../components/TitrePage';
import PageErreur from './PageErreur';
import { CLES_PERSONNES, parametres } from './personnes.config';

const VIDE: DonneesPersonne = { titre: 'Mr', prenom: '', nom: '', login: '', password: '' };

// -------------------------------------------------------------------------
// le formulaire lui-même
// -------------------------------------------------------------------------
interface PropsFormulaire {
  type: TypePersonnes;
  id: number | null; // null : création
  initial: DonneesPersonne;
}

function Formulaire({ type, id, initial }: PropsFormulaire) {
  const { t } = useTranslation();
  const navigate = useNavigate();
  const queryClient = useQueryClient();
  const envoi = useEnvoiFormulaire();
  const [saisie, setSaisie] = useState(initial);
  const cles = CLES_PERSONNES[type];
  const creation = id === null;

  function modifier(champ: keyof DonneesPersonne, valeur: string) {
    setSaisie((s) => ({ ...s, [champ]: valeur }));
    envoi.effacerErreur(champ);
  }

  async function enregistrer(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const messages = useMessages.getState();
    const reussi = await envoi.envoyer(async () => {
      const p = id === null ? await api.ajouter(type, saisie) : await api.modifier(type, id, saisie);
      messages.flash('succes', creation ? cles.ajoute : cles.modifie, parametres(p));
    });
    const cle = useMessages.getState().erreur?.cle;
    if (reussi) {
      await queryClient.invalidateQueries({ queryKey: [type] }); // la liste a changé
      navigate(`/${type}`);
    } else if (cle === 'ERRORS.DOCTOR_NOT_FOUND' || cle === 'ERRORS.PATIENT_NOT_FOUND') {
      // supprimé entre-temps par un autre ADMIN : retour à la liste
      messages.flash('erreur', cle);
      navigate(`/${type}`);
    }
  }

  return (
    <div className="row justify-content-center">
      <div className="col-md-8 col-lg-6">
        <div className="card p-4">
          <h5 className="card-title mb-3">{t(creation ? cles.ajouter : cles.modifier)}</h5>
          {/* noValidate : c'est le SERVEUR qui valide (il fait foi) */}
          <form noValidate onSubmit={enregistrer}>
            <ChampsPersonne valeurs={saisie} erreurs={envoi.erreurs} onChange={modifier} />

            <h6 className="mt-4 text-muted">{t('PERSONNES.ACCOUNT_SECTION')}</h6>
            <ChampSaisie className="mb-3" idChamp="login" libelle="PERSONNES.LOGIN_LABEL" longueurMax={30}
                         valeur={saisie.login} onChange={(v) => modifier('login', v)} erreur={envoi.erreurs.login}
                         aide={creation ? 'PERSONNES.LOGIN_HELP_CREATE' : 'PERSONNES.LOGIN_HELP_EDIT'} />
            {/* jamais pré-rempli : le mot de passe ne revient jamais du serveur */}
            <ChampSaisie className="mb-4" idChamp="password" type="password" libelle="PERSONNES.PASSWORD_LABEL" longueurMax={72}
                         autoComplete="new-password" valeur={saisie.password} onChange={(v) => modifier('password', v)}
                         erreur={envoi.erreurs.password} params={envoi.params}
                         aide={creation ? 'PERSONNES.PASSWORD_HELP_CREATE' : 'PERSONNES.PASSWORD_HELP_EDIT'} />

            <div className="d-flex gap-2">
              <button type="submit" className="btn btn-primary" disabled={envoi.enCours}>{t('PERSONNES.SAVE')}</button>
              <Link className="btn btn-secondary" to={`/${type}`}>{t('PERSONNES.BACK')}</Link>
            </div>
          </form>
        </div>
      </div>
    </div>
  );
}

// -------------------------------------------------------------------------
// la page : création, ou lecture de la fiche à modifier
// -------------------------------------------------------------------------
export default function PersonneFormulaire({ type }: { type: TypePersonnes }) {
  const { id: idUrl } = useParams(); // undefined pour une création
  const navigate = useNavigate();
  const cles = CLES_PERSONNES[type];
  const idValide = idUrl === undefined || /^\d+$/.test(idUrl);
  const id = idUrl === undefined || !idValide ? null : Number(idUrl);

  // modification : on lit la fiche (dont sa version)
  //   - gcTime: 0 : la fiche n'est pas gardée en cache après la visite ; la
  //     prochaine visite la relira, toujours à jour (sinon le formulaire
  //     démarrerait avec une version périmée : 409 CONCURRENT_UPDATE) ;
  //   - meta.erreurGeree : une erreur de lecture est traitée ICI, et non
  //     par le gestionnaire commun des erreurs (cf. main.tsx)
  const fiche = useQuery({
    queryKey: [type, id],
    queryFn: () => api.lire(type, id!),
    enabled: id !== null,
    gcTime: 0,
    meta: { erreurGeree: true },
  });

  // fiche inexistante (supprimée entre-temps...) : retour à la liste
  useEffect(() => {
    if (fiche.error instanceof ErreurApi) {
      useMessages.getState().flash('erreur', fiche.error.cle, fiche.error.params);
      navigate(`/${type}`);
    }
  }, [fiche.error, navigate, type]);

  // /medecins/abc/modifier : cette adresse n'existe pas
  if (!idValide) {
    return <PageErreur code={404} />;
  }

  const p = fiche.data;
  return (
    <>
      <TitrePage cle={id === null ? cles.ajouter : cles.modifier} />
      {id === null ? (
        <Formulaire type={type} id={null} initial={VIDE} />
      ) : (
        p && (
          <Formulaire type={type} id={id}
                      initial={{ titre: p.titre, prenom: p.prenom, nom: p.nom, login: p.login ?? '', password: '', version: p.version }} />
        )
      )}
    </>
  );
}

Une seule page pour la création ([/medecins/ajouter]) et la modification ([/medecins/3/modifier]), en deux composants :

  • lignes 46-106 : le formulaire lui-même. Il n'est affiché qu'une fois la fiche lue, et DÉMARRE avec ses valeurs ([useState(initial)]) : pas besoin de les recopier plus tard, dans un effet, quand la fiche arrive :
    • lignes 55-58 : la saisie d'un champ, et son erreur disparaît ;
    • lignes 60-76 : l'enregistrement. Pour une modification, [saisie] contient la VERSION lue avec la fiche : si un autre ADMIN l'a modifiée entre-temps, le serveur répond 409 [ERRORS.CONCURRENT_UPDATE] ;
    • lignes 68-70 : en cas de succès, la liste a changé : on l'invalide, puis on y retourne, avec le message ;
    • lignes 71-75 : la fiche a été supprimée entre-temps : retour à la liste. Les autres erreurs restent affichées sur le formulaire ;
    • ligne 84 : [noValidate] : le navigateur ne vérifie pas les champs [required] ; c'est le SERVEUR qui valide, et il fait foi ;
    • ligne 91 : le champ du mot de passe n'est jamais pré-rempli : le mot de passe ne revient jamais du serveur ;
  • lignes 111-159 : la page :
    • ligne 112 : le paramètre [:id] de la route, absent pour une création ;
    • lignes 124-130 : la lecture de la fiche, pour une modification. [gcTime: 0] : la fiche n'est pas gardée en cache après la visite : la prochaine visite la relira, à jour (sinon le formulaire démarrerait avec une version périmée). [meta: { erreurGeree: true }] : une erreur de lecture est traitée ici, et non par le gestionnaire commun (cf. [main.tsx]) ;
    • lignes 133-138 : fiche inexistante (supprimée entre-temps, adresse tapée à la main...) : retour à la liste, avec le message ;
    • lignes 141-143 : [/medecins/abc/modifier] : cette adresse n'existe pas ;
    • lignes 149-156 : une création : le formulaire vide ; une modification : le formulaire, une fois la fiche lue. [login: p.login ?? ''] : le login d'une personne n'est envoyé qu'à l'ADMIN : il est facultatif dans le type [Personne].

10.4.10.6. src/pages/Inscription.tsx

// =========================================================================
// pages/Inscription.tsx - "Créer un compte client" (/inscription)
// -------------------------------------------------------------------------
// On n'arrive ici qu'après avoir recopié le captcha de la page de connexion
// (bouton "Créer un compte client") : le serveur a alors délivré un
// laissez-passer, que le garde de la route a vérifié (cf. gardes.ts).
// Mêmes champs que le formulaire de l'ADMIN, mais le login et le mot de
// passe sont obligatoires, avec confirmation. Équivalent de
// views/compte/inscription.hbs de l'application HTML.
// =========================================================================

import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { api } from '../api/rdvmedecins.api';
import type { Inscription as DonneesInscription } from '../api/types';
import { useMessages } from '../stores/messages';
import { useEnvoiFormulaire } from '../hooks/useEnvoiFormulaire';
import ChampsPersonne from '../components/ChampsPersonne';
import ChampSaisie from '../components/ChampSaisie';
import TitrePage from '../components/TitrePage';

// longueur minimale d'un mot de passe choisi par un patient : même valeur
// que le serveur (qui renvoie aussi {min} avec l'erreur PASSWORD_TOO_SHORT)
const MOT_DE_PASSE_MIN = '6';

export default function Inscription() {
  const { t } = useTranslation();
  const navigate = useNavigate();
  const envoi = useEnvoiFormulaire();
  const [saisie, setSaisie] = useState<DonneesInscription>({
    titre: 'Mr', prenom: '', nom: '', login: '', password: '', confirmation: '',
  });
  // les paramètres du message d'aide ET des erreurs du mot de passe
  const paramsPassword = { min: MOT_DE_PASSE_MIN, ...envoi.params };

  function modifier(champ: keyof DonneesInscription, valeur: string) {
    setSaisie((s) => ({ ...s, [champ]: valeur }));
    envoi.effacerErreur(champ);
  }

  async function creer(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    let login = '';
    const reussi = await envoi.envoyer(async () => {
      login = (await api.inscrire(saisie)).login;
    });
    const messages = useMessages.getState();
    if (reussi) {
      // retour à la page de connexion, login pré-rempli
      messages.flash('succes', 'INSCRIPTION.DONE', { login });
      navigate(`/login?login=${encodeURIComponent(login)}`);
    } else if (messages.erreur?.cle === 'ERRORS.CAPTCHA_BEFORE_ACCOUNT') {
      // laissez-passer expiré (15 minutes) : retour au captcha
      messages.flash('erreur', 'ERRORS.CAPTCHA_BEFORE_ACCOUNT');
      navigate('/login');
    }
  }

  return (
    <div className="row justify-content-center">
      <TitrePage cle="INSCRIPTION.TITLE" />
      <div className="col-md-8 col-lg-6">
        <div className="card p-4">
          <h5 className="card-title mb-1">{t('INSCRIPTION.TITLE')}</h5>
          <p className="text-muted small mb-3">{t('INSCRIPTION.INTRO')}</p>
          <form noValidate onSubmit={creer}>
            <ChampsPersonne valeurs={saisie} erreurs={envoi.erreurs} onChange={modifier} />

            <h6 className="mt-4 text-muted">{t('PERSONNES.ACCOUNT_SECTION')}</h6>
            <ChampSaisie className="mb-3" idChamp="login" libelle="PERSONNES.LOGIN_LABEL" longueurMax={30} autoComplete="username"
                         requis valeur={saisie.login} onChange={(v) => modifier('login', v)} erreur={envoi.erreurs.login}
                         aide="INSCRIPTION.LOGIN_HELP" />
            <div className="row g-3 mb-3">
              <ChampSaisie className="col-sm-6" idChamp="password" type="password" libelle="PERSONNES.PASSWORD_LABEL" longueurMax={72}
                           autoComplete="new-password" requis valeur={saisie.password} onChange={(v) => modifier('password', v)}
                           erreur={envoi.erreurs.password} params={paramsPassword} aide="INSCRIPTION.PASSWORD_HELP" />
              <ChampSaisie className="col-sm-6" idChamp="confirmation" type="password" libelle="COMPTE.CONFIRMATION_LABEL"
                           longueurMax={72} autoComplete="new-password" requis valeur={saisie.confirmation}
                           onChange={(v) => modifier('confirmation', v)} erreur={envoi.erreurs.confirmation} />
            </div>
            <div className="d-flex gap-2">
              <button type="submit" className="btn btn-primary" disabled={envoi.enCours}>{t('INSCRIPTION.SUBMIT')}</button>
              <Link className="btn btn-secondary" to="/login">{t('INSCRIPTION.BACK_TO_LOGIN')}</Link>
            </div>
          </form>
        </div>
      </div>
    </div>
  );
}

La création d'un compte de patient. On n'arrive ici qu'après avoir recopié le captcha de la page de connexion : le serveur a délivré un laissez-passer, que le garde de la route a vérifié.

  • ligne 25 : la longueur minimale d'un mot de passe, pour le message d'aide (« Au moins 6 caractères. »). Le serveur la renvoie aussi avec l'erreur [ERRORS.PASSWORD_TOO_SHORT] ;
  • ligne 35 : les paramètres du message d'aide ET des erreurs du mot de passe : ceux du serveur, s'il y en a, l'emportent ;
  • lignes 42-58 : la création :
    • ligne 52 : en cas de succès, retour à la page de connexion, login pré-rempli ;
    • lignes 53-57 : le laissez-passer a expiré (15 minutes) : retour à la page de connexion, pour un nouveau captcha.

10.4.10.7. src/pages/EspacePatient.tsx

// =========================================================================
// pages/EspacePatient.tsx - "Mon espace" (/mon-espace, rôle USER)
// -------------------------------------------------------------------------
// La page d'accueil du patient, juste après sa connexion :
//   - deux options : [Gérer mon compte] et [Voir un agenda] ;
//   - son rendez-vous à venir, s'il en a un, avec le moyen de l'annuler.
// Équivalent de views/patient/espace.hbs de l'application HTML.
// =========================================================================

import { Link } from 'react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api } from '../api/rdvmedecins.api';
import { horaire, jourLong, libelle } from '../formatage';
import { useMessages } from '../stores/messages';
import { confirmer } from '../stores/confirmation';
import TitrePage from '../components/TitrePage';

export default function EspacePatient() {
  const { t, i18n } = useTranslation();
  const queryClient = useQueryClient();
  const espace = useQuery({ queryKey: ['mon-espace'], queryFn: api.monEspace });

  async function annuler(idRv: number) {
    if (!(await confirmer('AGENDA.CANCEL_CONFIRM'))) {
      return;
    }
    const messages = useMessages.getState();
    try {
      await api.annulerRv(idRv);
      messages.afficherSucces('AGENDA.CANCELLED');
    } catch (e) {
      messages.afficherErreurApi(e);
    }
    // l'espace ET les agendas ont changé
    await queryClient.invalidateQueries({ queryKey: ['mon-espace'] });
    await queryClient.invalidateQueries({ queryKey: ['agenda'] });
  }

  const e = espace.data;
  if (!e) {
    return (
      <>
        <TitrePage cle="ESPACE.TITLE" />
        <p className="text-muted">{t('APP.LOADING')}</p>
      </>
    );
  }
  return (
    <>
      <TitrePage cle="ESPACE.TITLE" />
      <h4 className="mb-3">{t('ESPACE.HELLO', { nom: libelle(e.patient) })}</h4>
      <div className="row g-3 mb-4">
        <div className="col-sm-6">
          <Link className="card p-4 h-100 text-decoration-none carte-option" to="/mon-compte">
            <h5 className="mb-1">{t('ESPACE.MANAGE_ACCOUNT')}</h5>
            <span className="text-muted">{t('ESPACE.MANAGE_ACCOUNT_HELP')}</span>
          </Link>
        </div>
        <div className="col-sm-6">
          <Link className="card p-4 h-100 text-decoration-none carte-option" to="/agenda">
            <h5 className="mb-1">{t('ESPACE.VIEW_AGENDA')}</h5>
            <span className="text-muted">{t('ESPACE.VIEW_AGENDA_HELP')}</span>
          </Link>
        </div>
      </div>

      <div className="card p-3">
        <h5>{t('ESPACE.MY_APPOINTMENT')}</h5>
        {/* normalement un seul (règle "un rendez-vous à venir à la fois"),
            mais la page sait en afficher plusieurs */}
        {e.rendezVous.map((rv, i) => (
          <div key={rv.id} className={`d-flex flex-wrap justify-content-between align-items-center gap-2 py-2 ${i < e.rendezVous.length - 1 ? 'border-bottom' : ''}`}>
            <div>
              <div className="fw-semibold">{jourLong(rv.jour, i18n.language)}, {horaire(rv.creneau)}</div>
              <div className="text-muted">
                {libelle(rv.medecin)} -{' '}
                {/* l'agenda du médecin ce jour-là : le patient y voit son nom */}
                <Link to={`/agenda?${new URLSearchParams({ idMedecin: String(rv.medecin.id), jour: rv.jour })}`}>
                  {t('ESPACE.SEE_IN_AGENDA')}
                </Link>
              </div>
            </div>
            <button type="button" className="btn btn-outline-danger" onClick={() => annuler(rv.id)}>{t('ESPACE.CANCEL_APPOINTMENT')}</button>
          </div>
        ))}
        {e.rendezVous.length === 0 && <p className="text-muted mb-0">{t('ESPACE.NO_APPOINTMENT')}</p>}
      </div>
    </>
  );
}

La page d'accueil du patient : deux options, « Gérer mon compte » et « Voir un agenda », et son rendez-vous à venir, qu'il peut annuler.

  • ligne 22 : l'espace du patient. [queryFn: api.monEspace] : la fonction elle-même, sans l'enrober dans une fonction fléchée : elle n'a pas de paramètre ;
  • lignes 24-38 : l'annulation, après confirmation. L'espace ET les agendas ont changé : on invalide les deux ;
  • lignes 41-48 : tant que l'espace n'est pas lu, « Chargement... » ;
  • lignes 55-58 : une carte [Bootstrap] entière est un lien : [<Link>] accepte n'importe quel contenu ;
  • lignes 72-86 : le rendez-vous à venir. Normalement un seul (la règle « un rendez-vous à venir à la fois »), mais la page sait en afficher plusieurs ;
  • ligne 79 : un lien vers l'agenda du médecin ce jour-là, où le patient voit son nom.

10.4.10.8. src/pages/MonCompte.tsx

// =========================================================================
// pages/MonCompte.tsx - "Gérer mon compte" (/mon-compte, rôle USER)
// -------------------------------------------------------------------------
// Deux formulaires indépendants, comme views/patient/compte.hbs :
//   1. la modification (identité, login, nouveau mot de passe facultatif) ;
//   2. la suppression du compte - qui annule aussi ses rendez-vous.
// L'un comme l'autre exigent le mot de passe ACTUEL (vérifié par le serveur).
// Deux formulaires, donc deux états et deux useEnvoiFormulaire (deux jeux
// d'erreurs). Comme pour PersonneFormulaire, la modification n'est
// affichée qu'une fois le compte lu, et démarre avec ses valeurs.
// =========================================================================

import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api } from '../api/rdvmedecins.api';
import type { ModificationMonCompte, MonCompte as Compte } from '../api/types';
import { useSession } from '../stores/session';
import { useMessages } from '../stores/messages';
import { confirmer } from '../stores/confirmation';
import { useEnvoiFormulaire } from '../hooks/useEnvoiFormulaire';
import ChampsPersonne from '../components/ChampsPersonne';
import ChampSaisie from '../components/ChampSaisie';
import TitrePage from '../components/TitrePage';

// -------------------------------------------------------------------------
// 1. la modification
// -------------------------------------------------------------------------
function Modification({ compte }: { compte: Compte }) {
  const { t } = useTranslation();
  const navigate = useNavigate();
  const queryClient = useQueryClient();
  const envoi = useEnvoiFormulaire();
  const [saisie, setSaisie] = useState<ModificationMonCompte>({ ...compte, password: '', confirmation: '', motDePasseActuel: '' });

  function modifier(champ: keyof ModificationMonCompte, valeur: string) {
    setSaisie((s) => ({ ...s, [champ]: valeur }));
    envoi.effacerErreur(champ);
  }

  async function enregistrer(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const reussi = await envoi.envoyer(async () => {
      // le serveur a délivré un NOUVEAU jeton (nom, login) : la barre de
      // navigation doit suivre
      useSession.getState().mettreAJour(await api.modifierMonCompte(saisie));
      useMessages.getState().flash('succes', 'MON_COMPTE.UPDATED');
    });
    if (reussi) {
      await queryClient.invalidateQueries({ queryKey: ['mon-espace'] });
      navigate('/mon-espace');
    } else {
      // jamais de mot de passe laissé dans le champ après un envoi refusé ;
      // on change la valeur SANS passer par modifier(), pour ne pas effacer
      // l'erreur que le serveur vient d'y attacher
      setSaisie((s) => ({ ...s, motDePasseActuel: '' }));
    }
  }

  return (
    <div className="card p-4 mb-4">
      <h5 className="card-title mb-3">{t('MON_COMPTE.TITLE')}</h5>
      <form noValidate onSubmit={enregistrer}>
        <ChampsPersonne valeurs={saisie} erreurs={envoi.erreurs} onChange={modifier} />

        <h6 className="mt-4 text-muted">{t('PERSONNES.ACCOUNT_SECTION')}</h6>
        <ChampSaisie className="mb-3" idChamp="login" libelle="PERSONNES.LOGIN_LABEL" longueurMax={30} autoComplete="username"
                     requis valeur={saisie.login} onChange={(v) => modifier('login', v)} erreur={envoi.erreurs.login} />
        <div className="row g-3 mb-3">
          <ChampSaisie className="col-sm-6" idChamp="password" type="password" libelle="MON_COMPTE.NEW_PASSWORD_LABEL"
                       longueurMax={72} autoComplete="new-password" valeur={saisie.password}
                       onChange={(v) => modifier('password', v)} erreur={envoi.erreurs.password} params={envoi.params}
                       aide="PERSONNES.PASSWORD_HELP_EDIT" />
          <ChampSaisie className="col-sm-6" idChamp="confirmation" type="password" libelle="COMPTE.CONFIRMATION_LABEL"
                       longueurMax={72} autoComplete="new-password" valeur={saisie.confirmation}
                       onChange={(v) => modifier('confirmation', v)} erreur={envoi.erreurs.confirmation} />
        </div>
        <ChampSaisie className="mb-4" idChamp="motDePasseActuel" type="password" libelle="MON_COMPTE.CURRENT_PASSWORD_LABEL"
                     longueurMax={72} autoComplete="current-password" requis valeur={saisie.motDePasseActuel}
                     onChange={(v) => modifier('motDePasseActuel', v)} erreur={envoi.erreurs.motDePasseActuel}
                     aide="MON_COMPTE.CURRENT_PASSWORD_HELP" />
        <div className="d-flex gap-2">
          <button type="submit" className="btn btn-primary" disabled={envoi.enCours}>{t('PERSONNES.SAVE')}</button>
          <Link className="btn btn-secondary" to="/mon-espace">{t('MON_COMPTE.BACK')}</Link>
        </div>
      </form>
    </div>
  );
}

// -------------------------------------------------------------------------
// 2. la suppression : un formulaire d'un seul champ
// -------------------------------------------------------------------------
function Suppression() {
  const { t } = useTranslation();
  const navigate = useNavigate();
  const envoi = useEnvoiFormulaire();
  const [motDePasseActuel, setMotDePasseActuel] = useState('');

  async function supprimer(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    if (!(await confirmer('MON_COMPTE.DELETE_CONFIRM'))) {
      return;
    }
    const reussi = await envoi.envoyer(() => api.supprimerMonCompte(motDePasseActuel));
    if (reussi) {
      useSession.getState().oublier(); // le serveur a effacé le cookie du jeton
      useMessages.getState().flash('succes', 'MON_COMPTE.DELETED');
      navigate('/login');
    }
  }

  return (
    <div className="card p-4 border-danger">
      <h5 className="card-title text-danger mb-1">{t('MON_COMPTE.DELETE_TITLE')}</h5>
      <p className="text-muted small">{t('MON_COMPTE.DELETE_HELP')}</p>
      <form className="d-flex flex-wrap gap-2 align-items-end" noValidate onSubmit={supprimer}>
        <ChampSaisie className="flex-grow-1" idChamp="motDePasseSuppression" type="password" libelle="MON_COMPTE.CURRENT_PASSWORD_LABEL"
                     longueurMax={72} autoComplete="current-password" requis valeur={motDePasseActuel}
                     onChange={(v) => {
                       setMotDePasseActuel(v);
                       envoi.effacerErreur('motDePasseActuel');
                     }}
                     erreur={envoi.erreurs.motDePasseActuel} />
        <button type="submit" className="btn btn-danger" disabled={envoi.enCours}>{t('MON_COMPTE.DELETE_SUBMIT')}</button>
      </form>
    </div>
  );
}

// -------------------------------------------------------------------------
// la page
// -------------------------------------------------------------------------
export default function MonCompte() {
  const { t } = useTranslation();
  // gcTime: 0 : relu à chaque visite (sa version doit être à jour)
  const compte = useQuery({ queryKey: ['mon-compte'], queryFn: api.monCompte, gcTime: 0 });

  return (
    <div className="row justify-content-center">
      <TitrePage cle="MON_COMPTE.TITLE" />
      <div className="col-md-8 col-lg-6">
        {compte.data ? <Modification compte={compte.data} /> : <p className="text-muted">{t('APP.LOADING')}</p>}
        <Suppression />
      </div>
    </div>
  );
}

Deux formulaires indépendants, donc deux composants, chacun avec son état et son [useEnvoiFormulaire] (deux jeux d'erreurs) :

  • lignes 30-90 : la modification de l'identité, du login, et, facultativement, du mot de passe :
    • ligne 35 : le formulaire démarre avec les valeurs du compte (dont sa version), les champs des mots de passe vides ;
    • ligne 47 : le serveur a délivré un NOUVEAU jeton, avec le nouveau nom et le nouveau login, et renvoie l'utilisateur : on le range dans le magasin de session, et la barre de navigation suit ;
    • lignes 53-58 : un envoi refusé : jamais de mot de passe laissé dans un champ. On change la valeur SANS passer par [modifier()], pour ne pas effacer l'erreur que le serveur vient d'attacher au champ ;
  • lignes 95-130 : la suppression du compte, qui annule aussi ses rendez-vous :
    • ligne 99 : un formulaire d'un seul champ : un état d'une seule chaîne ;
    • lignes 107-111 : le compte est supprimé, et le serveur a effacé le cookie du jeton : le client oublie l'utilisateur, puis la page de connexion, avec le message ;
  • lignes 135-149 : la page. [gcTime: 0] : le compte est relu à chaque visite, sa version doit être à jour. La suppression est affichée tout de suite ; la modification, une fois le compte lu.

10.4.10.9. src/pages/PageErreur.tsx

// =========================================================================
// pages/PageErreur.tsx - la page d'erreur : 404 (adresse inconnue) ou 403
// (rôle insuffisant, cf. gardes.ts). Équivalent de views/erreur.hbs.
// =========================================================================

import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import TitrePage from '../components/TitrePage';

export default function PageErreur({ code }: { code: 403 | 404 }) {
  const { t } = useTranslation();
  return (
    <div className="card p-4">
      <TitrePage cle="ERROR_PAGE.TITLE" />
      <h4 className="text-danger">{t('ERROR_PAGE.TITLE')} {code}</h4>
      <p>{t(code === 403 ? 'ERRORS.FORBIDDEN' : 'ERRORS.NOT_FOUND')}</p>
      <Link to="/" className="btn btn-primary align-self-start">{t('ERROR_PAGE.BACK')}</Link>
    </div>
  );
}

La page d'erreur : 404 (adresse inconnue) ou 403 (rôle insuffisant). [code: 403 | 404] : la prop ne peut prendre que ces deux valeurs.