Skip to content

10. Case Study: The React Client for the RdvMedecins Application

10.1. The Problem

The [RdvMedecins] application allows users to schedule appointments at a doctor’s office. The [NestJS] course wrote it as a HTML application, in which the server generated the pages. Here, we are writing the client application [React]: the SAME pages, generated in the browser from the JSON data on the server described in the chapter “The RdvMedecins Server: A Black Box.”

The client must provide everything that the HTML application provided:

  • a login page, protected by a CAPTCHA, with the option to create a patient account;
  • a doctor’s schedule for a given day, with appointment booking (patient selection window) and cancellation;
  • for the administrator, management of doctors and clients (list, add, edit, delete);
  • for the patient, a personal dashboard (showing upcoming appointments, which they can cancel) and account management;
  • two languages: French and English;
  • unique URLs ([/schedule?doctorId=1&day=...]), which can be reloaded, bookmarked, and navigated using the browser’s Back/Forward buttons.

10.2. Installation and Execution

The JSON server must be installed and running (see the chapter “The RdvMedecins Server: A Black Box”). To use the client in a browser, you do not need the CAPTCHA test mode: you can leave the [CAPTCHA_TEST] field blank in the server’s [.env] file.

In a console window open to the [rdvmedecins-react] folder:

npm install
npm start

then open http://localhost:5173 in a browser. The demo accounts are listed on the login page (password = login).

The examples also use port 5173: first, stop the currently running example ([Ctrl-C]). You can also start the client on a different port: [npm start -- --port 5174].

10.3. Using the Application

10.3.1. The login page

Image

An incorrect password: the message appears at the bottom of the page, and a new image is displayed (each image can only be used once):

Image

10.3.2. The calendar, as seen by the administrator and doctors

Logged in as [admin], you are taken to the calendar:

Image

Select a doctor and a day, then click “View schedule.” The URL becomes [/schedule?doctorId=1&day=2026-10-05]:

Image

“Book” opens the booking window. The URL becomes [/schedule?doctorId=1&day=2026-10-05&book=3]: reloading the page (F5) reopens the same window.

Image

The appointment is saved:

Image

"Cancel" prompts for confirmation:

Image

A doctor is taken directly to THEIR OWN daily schedule:

Image

10.3.3. The Patient Portal

A patient ([martin]) accesses “My area”:

Image

In a calendar, they see “Booked” next to other patients’ appointment slots:

Image

They book an appointment for themselves without selecting a patient:

Image

His appointment appears under his name; the “Book” button is no longer available (only one upcoming appointment), and a message explains why:

Image

His dashboard shows this:

Image

"Manage my account": any changes require the current password; the errors are server-side:

Image

If he enters the address /doctors, which is reserved for the administrator:

Image

10.3.4. Managing doctors and clients

Image

An incorrectly filled-out entry:

Image

A successful addition:

Image

A homonym:

Image

The request to remove a doctor who has appointments is denied:

Image

10.3.5. Creating an Account

On the login page, enter the CAPTCHA and then click “Create a patient account.” The form was filled out incorrectly and was rejected by the server:

Image

Once the account is created, return to the login page; the login fields are pre-filled:

Image

10.3.6. In French

The buttons FR / EN translate the page on the spot, without making any requests to the server; the browser tab title is also translated:

Image

10.3.7. Session expired, unknown address

If the token expires (or if the cookie is deleted), the next request receives a 401 response; the client is redirected to the login page:

Image

An unknown address:

Image

10.4. Client architecture

rdvmedecins-react/
├── package.json, vite.config.ts, tsconfig.json
├── index.html                THE page of the application
├── public/favicon.svg        the tab icon
└── src/
    ├── main.tsx              the startup
    ├── router.ts             the router, the expired session, the flash messages
    ├── routes.tsx            the pages and their access rights
    ├── guards.ts             the router guards
    ├── styles.css
    ├── formatting.ts         the formatting
    ├── api/                  the API access layer
    ├── i18n/                 the languages
    ├── stores/               session, messages, confirmation (Zustand)
    ├── hooks/                form submission, the captcha
    ├── components/           the layout and the reusable components
    └── pages/                the pages

Image

The following table compares the HTML application from the [NestJS] course with this client:

Application HTML (NestJS)
React client
The server generates each page ([res.render])
The server returns JSON; the components of [src/pages/] generate the pages
[views/layout.hbs] and its partial views
[Layout.tsx] and the components of [src/components/]
the controller routes ([@Controller('schedule')])
client routes ([routes.tsx]); the server only has /api/... routes
[JwtAuthGuard] and [RolesGuard] redirect to /login or a 403 page
router guards; the server maintains its own guards, which return 401 or 403
flash message in a cookie
flash message in a store, [useMessages]
dictionaries read by the server, helper [{{t}}]
dictionaries on the client, [i18next]; the server returns only keys
language stored in a cookie
language stored in the [localStorage]
form rejected: the server reloads the page
form rejected: 400/409 response; the client displays the errors below the fields
Confirmation window: a small script, [public/js/app.js]
a store and a component, [ConfirmDialog]
The application works without JavaScript
The application IS JavaScript

The clients [Vue.js] and [Angular] from the previous lessons have exactly the same structure: the "stores" in [Pinia] and the services in [Angular] are here [Zustand] stores; the "composables" are the hooks, and the views are the pages.

All client files are listed below. They are extensively commented; their comments are part of the course.

10.4.1. Project configuration

10.4.1.1. package.json

{
  "name": "rdvmedecins-react",
  "private": true,
  "version": "1.0.0",
  "description": "React client of the medical appointment booking application [RdvMedecins] - client of the JSON server [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"
  }
}
  • Lines 8–10: [npm start] starts the development server; [npm run build] checks the types ([tsc --noEmit], see below) THEN builds the production version; [npm run preview] serves the built version for testing;
  • Lines 12–21: the libraries used by the application: [React], [React Router], [TanStack Query], [Zustand], [i18next], [Bootstrap];
  • Lines 22–28: Development tools: [Vite] and its extension for [React], [TypeScript], and the types of [React].

There is no form library ([React Hook Form], example 08): as in example 25, all input validation rules are checked by the server, and the client simply displays the results.

10.4.1.2. vite.config.ts

// =========================================================================
// vite.config.ts - the build tool of the client
// -------------------------------------------------------------------------
//   - npm start      : the development server (http://localhost:5173),
//                      which forwards /api/... to the JSON server (port 8080);
//   - npm run build  : the compiled application, in dist/;
//   - npm run preview: serves dist/ (with the same proxy), to try it out.
// =========================================================================

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

// the proxy: pages and API thus have the SAME origin (localhost:5173); the
// httpOnly cookie of the token travels without any configuration, and no CORS
const proxy = { '/api': 'http://localhost:8080' };

export default defineConfig({
  plugins: [react()],
  server: { port: 5173, proxy },
  preview: { port: 4173, proxy },
  // in production: the code of the libraries (React, React Router,
  // i18next...) in ONE separate file, "libraries-XXXX.js". It rarely
  // changes: the browser keeps it in its cache from one version to the next.
  build: {
    rolldownOptions: {
      output: { codeSplitting: { groups: [{ name: 'libraries', test: /node_modules/ }] } },
    },
  },
});
  • Line 15: the proxy from Example 22: /api/... requests are forwarded to the JSON server;
  • line 19: the development server;
  • line 20: the [npm run preview] server, with the same proxy;
  • lines 24–28: In production, the library code is stored in a separate file (see the “Deployment” chapter).

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"]
}
  • line 8: JSX is compiled into calls to [jsx(...)], which are imported automatically: there is no need for [import React] in every file;
  • line 9: the strict mode of [TypeScript];
  • line 14: an import used only for types is written as [import type]: it disappears during compilation;
  • line 15: only the [TypeScript] syntax that can simply be ERASED to obtain [JavaScript] is allowed (no [enum], no property parameters): this is what [Vite] does, which does not check types;
  • Line 16: Importing the JSON files (the dictionaries);
  • line 18: [TypeScript] does not generate any files: it only CHECKS ([tsc --noEmit]); [Vite] is the one that compiles.

10.4.2. Starting the application

10.4.2.1. index.html

<!doctype html>
<!--
  index.html - THE page of the application (Single Page Application).
  The server never sends any other: everything else is built in the
  browser by React, in <div id="root">. lang and title are updated
  by the application according to the language and the page displayed.
-->
<html lang="en">
<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>
  <!-- without JavaScript, a React application cannot display anything: we say so -->
  <noscript>RdvMedecins requires JavaScript. / RdvMedecins a besoin de JavaScript.</noscript>
  <script type="module" src="/src/main.tsx"></script>
</body>
</html>
  • line 16: the element where [React] displays the application;
  • line 18: without [JavaScript], a [React] application cannot display anything: this is stated;
  • line 19: the application’s entry point. During development, [Vite] compiles [main.tsx] on demand; in production, this line is replaced by the compiled file (see the “Deployment” chapter).

10.4.2.2. src/main.tsx

// =========================================================================
// main.tsx - the startup of the application
// -------------------------------------------------------------------------
//   - the style sheets (Bootstrap, styles.css);
//   - i18next (French and English);
//   - TanStack Query, which reads the server's data: its failed requests
//     display their message in the error panel, in ONE place
//     (except those that declare they handle their error themselves:
//     meta.errorHandled);
//   - the router, which displays the page of the current address.
// =========================================================================

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 { router } from './router';

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      if (!query.meta?.errorHandled) {
        useMessages.getState().showApiError(error);
      }
    },
  }),
  defaultOptions: {
    queries: {
      retry: false, // an error is displayed at once, without a new attempt
      refetchOnWindowFocus: false, // no re-read when coming back to the tab
    },
  },
});

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  </StrictMode>,
);
  • lines 17–19: the style sheets, followed by the configuration of [i18next], which is executed once when the module is loaded;
  • lines 23–37: the [TanStack Query] client (Example 24), with two settings:
    • lines 24–30: [onError] is called for EACH failed request: its error message is displayed in the error area, in a single location. A query that handles its own error declares it in its metadata: [meta: { errorHandled: true }];
    • Lines 33–34: By default, [TanStack Query] retries a failed request three times and reloads the data when you return to the tab: here, an error is displayed immediately, and the data changes only when requested;
  • line 42: the router (built in [router.ts]) displays the page for the current address.

10.4.2.3. src/router.ts

// =========================================================================
// router.ts - the router of the application, and what depends on it
// -------------------------------------------------------------------------
// The router is created here, outside any component, so that two
// mechanisms can use it:
//   1. the 401 response of a protected request (session expired): the
//      user is forgotten, the message is prepared, and we go to the
//      login page (see api/client.ts);
//   2. the "flash" messages: at the end of EACH navigation, the messages
//      of the previous page disappear, those left for the next
//      page appear (see stores/messages.ts).
// =========================================================================

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

export const router = createBrowserRouter(routes);

// 1. the expired session
whenSessionExpired(() => {
  useSession.getState().forget();
  useMessages.getState().flash('error', 'ERRORS.SESSION_EXPIRED');
  router.navigate('/login');
});

// 2. subscribe: the function is called on each change of the state of the
// router. A navigation is FINISHED when it is no longer in progress
// (idle) and the address has changed (each visited address has a unique key,
// location.key - including when only the parameters change: /schedule?...)
let locationKey = router.state.location.key;
router.subscribe((state) => {
  if (state.navigation.state === 'idle' && state.location.key !== locationKey) {
    locationKey = state.location.key;
    useMessages.getState().newPage();
  }
});
  • line 20: the router, in “data” mode (example 17), is created OUTSIDE any component: code that is not a component can thus use it ([router.navigate]);
  • lines 23–27: what to do when a protected request receives a 401 response (see [api/client.ts]): forget the user, prepare the message for the next page, go to the login page;
  • lines 33–39: [router.subscribe(function)]: the function is called whenever the router’s state changes. At the end of each navigation, the messages from the previous page disappear and the “flash” messages appear (see [stores/messages.ts]).

10.4.2.4. src/styles.css

/* =========================================================================
   styles.css - a few settings on top of Bootstrap
   (taken from public/css/styles.css of the HTML application)
   ========================================================================= */

body {
  background-color: #f5f7fa;
}

.free-slot:hover {
  background-color: #e9f7ef;
}

/* -------------------------------------------------------------------------
   .error-panel: the error messages, at the bottom of the page, on a light pink
   background (see components/ErrorPanel.tsx).
   position: sticky + bottom: if the page is taller than the window, the
   panel stays stuck to the bottom of the window instead of disappearing below the
   fold - without ever covering the content when the page is
   short. z-index: above the grey backdrop of the modal dialogs.
   pointer-events: none: the panel contains nothing clickable; if it
   overlaps a button, the click goes through.
   ------------------------------------------------------------------------- */
.error-panel {
  position: sticky;
  bottom: 1rem;
  z-index: 1060;
  pointer-events: none;
  margin-top: 1.5rem;
  padding: 0.75rem 1rem;
  background-color: #fdecef; /* light pink */
  border: 1px solid #f5c2c7;
  border-radius: 0.375rem;
  color: #842029;
}

/* the two main options of the patient's "My area" page */
.option-card {
  transition: border-color 0.15s, box-shadow 0.15s;
}

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

/* the captcha image (SVG of 150 x 50 pixels), inserted by dangerouslySetInnerHTML:
   a style of the global sheet, like all the others */
.captcha-image svg {
  display: block;
  border: 1px solid #dee2e6;
  border-radius: 0.375rem;
}

The stylesheet for the HTML application, reproduced almost verbatim. The only point to note is the error area (lines 24–35), which is positioned at the bottom of the window when the page is tall.

10.4.3. The access layer for API

10.4.3.1. src/api/types.ts

// =========================================================================
// api/types.ts - the JSON objects exchanged with the server
// -------------------------------------------------------------------------
// The translation, into TypeScript interfaces, of the API described in the chapter
// "The server, a black box". The compiler thus checks that the client
// only uses fields that really exist.
// =========================================================================

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

// POST /api/auth/login, GET /api/auth/me, PUT /api/my-account
export interface User {
  login: string;
  name: string; // display name: "Marie PELISSIER"
  role: Role;
}

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

// a title, a first name, a last name
export interface Identity {
  title: string;
  firstName: string;
  lastName: string;
}

// a doctor or a client: GET /api/doctors, GET /api/clients/:id...
export interface Person extends Identity {
  id: number;
  version: number; // optimistic lock: to send back with an update
  login?: string; // only for the ADMIN
}

// what is sent to create / update a person (ADMIN)
export interface PersonData extends Identity {
  login: string; // '' = default value (creation) or unchanged (update)
  password: string; // same
  version?: number;
}

export interface Slot {
  id: number;
  startHour: number;
  startMinute: number;
  endHour: number;
  endMinute: number;
}

// a row of the schedule, with the RIGHTS of the logged-in user
export interface ScheduleSlot extends Slot {
  free: boolean;
  patient: Identity | null; // null: free, or taken by another patient
  mine: boolean; // the appointment of the logged-in patient
  bookable: boolean;
  appointmentId: number | null; // filled in if the user can cancel
}

// GET /api/schedule?doctorId=...&day=...
export interface Schedule {
  doctor: Person;
  day: string; // "yyyy-MM-dd"
  slots: ScheduleSlot[];
  info: string | null; // translation key (patient who already has an appointment)
}

// GET /api/my-area
export interface MyArea {
  patient: Identity;
  appointments: { id: number; day: string; slot: Slot; doctor: Person }[];
}

// GET /api/my-account
export interface MyAccount extends Identity {
  login: string;
  version: number;
}

// PUT /api/my-account
export interface MyAccountUpdate extends Identity {
  login: string;
  password: string; // '' = unchanged
  confirmation: string;
  currentPassword: string;
  version: number;
}

// POST /api/register
export interface Registration extends Identity {
  login: string;
  password: string;
  confirmation: string;
}

The JSON objects from API, described in the chapter “The RdvMedecins Server: A Black Box,” translated into [TypeScript] interfaces:

  • lines 25–29: a title, a first name, a last name—what doctors, clients, and patients have in common when making appointments;
  • lines 32–36: a doctor or a client. [version]: the server’s optimistic lock; a modification returns the read version: if someone has modified the record in the meantime, the server refuses ([ERRORS.CONCURRENT_UPDATE]);
  • lines 54–60: a calendar entry. The server specifies the RIGHTS of the logged-in user: [bookable], [appointmentId] (filled in if the user can cancel); the client does not need to recalculate them;
  • Line 67: a translation key, for patients who already have an appointment.

10.4.3.2. src/api/api-error.ts

// =========================================================================
// api/api-error.ts - ONE class for all the errors of the API
// -------------------------------------------------------------------------
// Taken from common/api-error.ts of the examples. Any error of a request
// (4xx / 5xx response of the server, server unreachable, response of a proxy)
// becomes an ApiError, which carries the translation KEY of the message, its
// parameters, and the error of each field of a rejected form.
// =========================================================================

export class ApiError extends Error {
  readonly statusCode: number; // 0: the server did not respond
  readonly key: string; // translation key: 'ERRORS.LOGIN_TAKEN'
  readonly params: Record<string, string>;
  readonly fields: Record<string, string>; // error of each field

  constructor(statusCode: number, key: string, params: Record<string, string> = {}, fields: Record<string, string> = {}) {
    super(key);
    this.name = 'ApiError';
    this.statusCode = statusCode;
    this.key = key;
    this.params = params;
    this.fields = fields;
  }
}

// an error response (4xx, 5xx) -> an ApiError
export async function fromResponse(response: Response): Promise<ApiError> {
  let body: { key?: string; params?: Record<string, string>; fields?: Record<string, string> } = {};
  try {
    const json: unknown = await response.json();
    if (typeof json === 'object' && json !== null) {
      body = json;
    }
  } catch {
    // not JSON: it is not our server that answered
  }
  return new ApiError(
    response.status,
    // no key: it is not our server that answered, but a proxy
    // (Vite: 500 or 502 without a JSON body; nginx: 502, 504...)
    body.key ?? (response.status >= 500 ? 'ERRORS.SERVER_UNREACHABLE' : 'ERRORS.SERVER_ERROR'),
    body.params,
    body.fields,
  );
}

The [ApiError] class from Example 24, unchanged: ANY request error becomes a [ApiError], which contains the message’s translation key, its parameters, and the error for each field in a rejected form.

10.4.3.3. src/api/client.ts

// =========================================================================
// api/client.ts - ALL the requests to the API go through here
// -------------------------------------------------------------------------
// Taken from common/api.ts of the examples, with one new feature: the
// centralized handling of the 401 response.
//
// A 401 can happen at any time: the token has expired (2 hours), the
// account has been deleted, the server has restarted with another secret
// key... Rather than handling this case in each component, we handle it
// HERE, once. But this file knows neither the router nor the
// messages (it depends on nothing): it calls a function that was given
// to it at startup (whenSessionExpired, see router.ts), which forgets
// the user and sends back to the login page.
//
// Some requests handle their 401 themselves (the login, GET
// /api/auth/me...): they are "public" (see rdvmedecins.api.ts).
// =========================================================================

import { fromResponse, ApiError } from './api-error';

// what to do when the server answers 401 to a protected request
let sessionExpired: () => void = () => {};

export function whenSessionExpired(action: () => void): void {
  sessionExpired = action;
}

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

// isPublic: true for the routes that handle the 401 themselves
export async function request<T>(method: Method, url: string, body?: unknown, isPublic = false): Promise<T> {
  let response: Response;
  try {
    response = await fetch(`/api${url}`, {
      method,
      headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
  } catch {
    // no response: network down, server stopped
    throw new ApiError(0, 'ERRORS.SERVER_UNREACHABLE');
  }
  if (!response.ok) {
    const error = await fromResponse(response);
    if (error.statusCode === 401 && !isPublic) {
      sessionExpired();
    }
    throw error;
  }
  return (await response.json()) as T;
}

The [request] function from Example 24, with a new feature: the 401 response.

  • Lines 22–26: the function to call when a protected request receives a 401 response. This file has no dependencies (neither on the router nor on the messages): [router.ts] assigns this function to it at startup. This avoids a circular dependency (the client imports the router, which imports the routes, which import the pages, which import the client...);
  • line 31: Some requests handle their own 401 errors: the login (incorrect password: 401), [GET /api/auth/me] (no one is logged in: 401)...;
  • Lines 45–47: A protected request receives a 401: the session has expired. This is reported, THEN the promise is rejected as with any other error: the component waiting for the response has no specific action to take.

10.4.3.4. src/api/rdvmedecins.api.ts

// =========================================================================
// api/rdvmedecins.api.ts - one function per route of the API
// -------------------------------------------------------------------------
// The "data access layer" of the client: the components call these
// typed functions, without knowing anything about URLs, fetch or JSON. If
// the API changes, only this file changes.
// Each function returns a PROMISE: the components chain the
// requests with async / await. On failure, the promise is rejected
// with an ApiError (see client.ts).
// =========================================================================

import { request } from './client';
import type {
  Schedule,
  Captcha,
  PersonData,
  Registration,
  MyAccountUpdate,
  MyAccount,
  MyArea,
  Person,
  User,
} from './types';

// /api/doctors or /api/clients: both are managed in the same way
export type PersonType = 'doctors' | 'clients';

type Empty = Record<string, never>;

export const api = {
  // ------------------------------------------------ login (public routes)
  captcha: () => request<Captcha>('GET', '/captcha', undefined, true),
  me: () => request<User>('GET', '/auth/me', undefined, true),
  logIn: (login: string, password: string, captchaId: string, captcha: string) =>
    request<User>('POST', '/auth/login', { login, password, captchaId, captcha }, true),
  logOut: () => request<Empty>('POST', '/auth/logout', {}, true),

  // ------------------------------------------------ account creation (public)
  registrationAccess: (captchaId: string, captcha: string) =>
    request<Empty>('POST', '/register/access', { captchaId, captcha }, true),
  registrationAccessStatus: () => request<{ valid: boolean }>('GET', '/register/access', undefined, true),
  register: (data: Registration) => request<{ login: string }>('POST', '/register', data, true),

  // ------------------------------------------------ schedule and appointments
  // doctorId null: for a logged-in doctor, THEIR schedule
  schedule(doctorId: number | null, day: string) {
    const params = new URLSearchParams({ day });
    if (doctorId !== null) {
      params.set('doctorId', String(doctorId));
    }
    return request<Schedule>('GET', `/schedule?${params}`);
  },
  book: (day: string, slotId: number, clientId: number | null) =>
    request<{ id: number }>('POST', '/appointments', { day, slotId, clientId: clientId ?? undefined }),
  cancelAppointment: (appointmentId: number) => request<Empty>('DELETE', `/appointments/${appointmentId}`),

  // ------------------------------------------------ doctors and clients
  list: (type: PersonType) => request<Person[]>('GET', `/${type}`),
  read: (type: PersonType, id: number) => request<Person>('GET', `/${type}/${id}`),
  add: (type: PersonType, data: PersonData) => request<Person>('POST', `/${type}`, data),
  edit: (type: PersonType, id: number, data: PersonData) => request<Person>('PUT', `/${type}/${id}`, data),
  remove: (type: PersonType, id: number) => request<Empty>('DELETE', `/${type}/${id}`),

  // ------------------------------------------------ the patient's area
  myArea: () => request<MyArea>('GET', '/my-area'),
  myAccount: () => request<MyAccount>('GET', '/my-account'),
  editMyAccount: (data: MyAccountUpdate) => request<User>('PUT', '/my-account', data),
  deleteMyAccount: (currentPassword: string) => request<Empty>('DELETE', '/my-account', { currentPassword }),
};

One function per route in API, typed: the components call [api.schedule(1, '2026-10-05')] without knowing anything about URL or JSON. If API changes, only this file changes.

  • lines 32–36: the connection routes, which are public (last parameter of [request]: [true]);
  • lines 39–42: account creation, in two steps: solving the CAPTCHA on the login page provides a “pass” (a cookie), which the route [POST /api/register] requires;
  • lines 46–52: [URLSearchParams] generates the address parameters (and encodes them). For a logged-in doctor, there is no [doctorId]: the server returns THEIR schedule;
  • line 54: a patient makes a reservation for themselves: no [clientId]. [JSON.stringify] omits the [undefined] properties;
  • lines 58–62: doctors and clients are handled the same way: the type is a parameter;
  • line 28: the type of an empty object [{}], which certain routes return.

10.4.4. Languages

10.4.4.1. src/i18n/i18n.ts

// =========================================================================
// i18n/i18n.ts - French and English (i18next, see example 21)
// -------------------------------------------------------------------------
// The fr.json and en.json dictionaries are those of the NestJS course
// (i18n/*.json), completed with a few keys specific to the client
// (ERRORS.SERVER_UNREACHABLE...). The JSON server only returns KEYS
// ('ERRORS.LOGIN_TAKEN'): they are the same as those of the dictionaries.
//
// The chosen language is stored in localStorage (a preference, not
// sensitive data); failing that, the browser's language is used.
// =========================================================================

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

export type Language = 'fr' | 'en';
export const LANGUAGES: Language[] = ['fr', 'en'];
const STORAGE_KEY = 'rdvmedecins.language';

function initialLanguage(): Language {
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    if (stored === 'fr' || stored === 'en') {
      return stored;
    }
  } catch {
    // localStorage unavailable (strict private browsing...): never mind
  }
  return navigator.language.toLowerCase().startsWith('fr') ? 'fr' : 'en';
}

// on each change of language: store it, and update the
// lang attribute of the page (screen readers, spell checkers, hyphenation)
i18n.on('languageChanged', (language) => {
  document.documentElement.lang = language;
  try {
    localStorage.setItem(STORAGE_KEY, language);
  } catch {
    // of no importance
  }
});

i18n.use(initReactI18next).init({
  // the dictionaries are part of the application's code: no request
  resources: { fr: { translation: fr }, en: { translation: en } },
  lng: initialLanguage(),
  fallbackLng: 'en',
  interpolation: { escapeValue: false }, // React already escapes everything
});

export default i18n;

The configuration for Example 21:

  • lines 22–32: the stored language, otherwise the browser’s language. Access to [localStorage] may fail (strict private browsing): [try / catch];
  • lines 36–43: each time the language changes, it is saved and the [lang] attribute of the page is updated;
  • line 47: the dictionaries, imported as modules, are part of the compiled code and require no additional requests;
  • line 50: [React] already escapes everything it displays: no need to do it twice.

There is no equivalent to the “title strategy” of [Angular]: each page declares its title with the [<title>] tag, which [React] 19 places in the document header (see [components/PageTitle.tsx]).

10.4.4.2. Dictionaries

The dictionaries [fr.json] and [en.json] are those for the HTML application in the [NestJS] course, supplemented with a few client-specific keys ([ERRORS.SERVER_UNREACHABLE]...). Their parameter syntax, [{{min}}], is exactly the same as that of [i18next]. The JSON server returns the same keys.

{
  "APP": {
    "TITLE": "RdvMedecins",
    "LOGOUT": "Se déconnecter",
    "MENU_SCHEDULE": "Agenda",
    "MENU_DOCTORS": "Médecins",
    "MENU_PATIENTS": "Clients",
    "MENU_MY_AREA": "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_SCHEDULE": "Voir l'agenda"
  },
  "SCHEDULE": {
    "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 {{time}}",
    "PATIENT_LABEL": "Patient",
    "CHOOSE_PATIENT": "-- choisir un patient --",
    "CANCEL": "Annuler",
    "CONFIRM": "Confirmer"
  },
  "PERSONS": {
    "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"
  },
  "DOCTORS": {
    "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 {{name}} a été ajouté (login : {{login}}).",
    "UPDATED": "Le médecin {{name}} a été modifié.",
    "DELETED": "Le médecin {{name}} 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 {{name}} a été ajouté (login : {{login}}).",
    "UPDATED": "Le client {{name}} a été modifié.",
    "DELETED": "Le client {{name}} 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."
  },
  "REGISTRATION": {
    "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}}."
  },
  "ACCOUNT": {
    "CONFIRMATION_LABEL": "Confirmation du mot de passe"
  },
  "AREA": {
    "TITLE": "Mon espace",
    "HELLO": "Bonjour {{name}}",
    "MANAGE_ACCOUNT": "Gérer mon compte",
    "MANAGE_ACCOUNT_HELP": "Modifier mes informations, mon mot de passe, ou supprimer mon compte.",
    "VIEW_SCHEDULE": "Voir un agenda",
    "VIEW_SCHEDULE_HELP": "Consulter l'agenda d'un médecin et réserver un créneau.",
    "MY_APPOINTMENT": "Mon rendez-vous",
    "SEE_IN_SCHEDULE": "voir dans l'agenda",
    "CANCEL_APPOINTMENT": "Annuler ce rendez-vous",
    "NO_APPOINTMENT": "Vous n'avez aucun rendez-vous à venir."
  },
  "MY_ACCOUNT": {
    "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_SCHEDULE": "Schedule",
    "MENU_DOCTORS": "Doctors",
    "MENU_PATIENTS": "Patients",
    "MENU_MY_AREA": "My area",
    "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_SCHEDULE": "View schedule"
  },
  "SCHEDULE": {
    "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 {{time}}",
    "PATIENT_LABEL": "Patient",
    "CHOOSE_PATIENT": "-- choose a patient --",
    "CANCEL": "Cancel",
    "CONFIRM": "Confirm"
  },
  "PERSONS": {
    "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"
  },
  "DOCTORS": {
    "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 {{name}} has been added (login: {{login}}).",
    "UPDATED": "Doctor {{name}} has been updated.",
    "DELETED": "Doctor {{name}} 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 {{name}} has been added (login: {{login}}).",
    "UPDATED": "Patient {{name}} has been updated.",
    "DELETED": "Patient {{name}} 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."
  },
  "REGISTRATION": {
    "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}}."
  },
  "ACCOUNT": {
    "CONFIRMATION_LABEL": "Confirm password"
  },
  "AREA": {
    "TITLE": "My area",
    "HELLO": "Hello {{name}}",
    "MANAGE_ACCOUNT": "Manage my account",
    "MANAGE_ACCOUNT_HELP": "Change my details or password, or delete my account.",
    "VIEW_SCHEDULE": "View a schedule",
    "VIEW_SCHEDULE_HELP": "See a doctor's schedule and book a slot.",
    "MY_APPOINTMENT": "My appointment",
    "SEE_IN_SCHEDULE": "see in the schedule",
    "CANCEL_APPOINTMENT": "Cancel this appointment",
    "NO_APPOINTMENT": "You have no upcoming appointment."
  },
  "MY_ACCOUNT": {
    "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 area",
    "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. The stores

10.4.5.1. src/stores/session.ts

// =========================================================================
// stores/session.ts - WHO is logged in?
// -------------------------------------------------------------------------
// The logged-in user (or null), in a Zustand store (see example
// 20): readable by the components (useSession) AND by the code that is
// not a component (the guards of the router: useSession.getState()).
//
// The JWT token itself is NOT here: it is in an httpOnly cookie that
// only the browser handles. The store only keeps the DESCRIPTION of
// the user, returned by the server ({ login, name, role }).
//
// At startup (and after an F5), the store is empty: check() asks
// the server, only once, who is logged in (GET /api/auth/me).
// =========================================================================

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

interface Session {
  user: User | null;
  check: () => Promise<void>;
  logIn: (login: string, password: string, captchaId: string, captcha: string) => Promise<void>;
  update: (u: User) => void;
  logOut: () => Promise<void>;
  forget: () => void;
}

// has the question "who is logged in?" already been asked to the server?
// (a module variable: it does not have to be displayed)
let checking: Promise<void> | null = null;

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

  // called by the router's guard before EACH navigation; only questions
  // the server the first time (the promise is kept and reused)
  check() {
    checking ??= api
      .me()
      .then((u) => set({ user: u }))
      .catch(() => set({ user: null })); // 401: nobody; or server unreachable
    return checking;
  },

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

  // the user has changed their name or login (PUT /api/my-account)
  update: (u) => set({ user: u }),

  async logOut() {
    try {
      await api.logOut();
    } finally {
      get().forget();
    }
  },

  // without asking the server anything: session expired, account deleted
  forget: () => set({ user: null }),
}));

// what is deduced from the user: ordinary functions
export const role = (u: User | null): Role | null => u?.role ?? null;
export const isAdmin = (u: User | null) => role(u) === 'ADMIN';
export const isPatient = (u: User | null) => role(u) === 'USER';
// ADMIN and DOCTOR: book and cancel the appointments of any patient
export const isManager = (u: User | null) => role(u) === 'ADMIN' || role(u) === 'DOCTOR';
// the home page of each role, right after logging in
export const homePage = (u: User | null) => (isPatient(u) ? '/my-area' : '/schedule');

The logged-in user, in a store [Zustand] (example 20). A store can be read by the components ([useSession((s) => s.user)]) AND via the code that is not a component: [useSession.getState()] (the router guards).

  • Line 21: the user’s DESCRIPTION ([{ login, name, role }]). The token JWT, on the other hand, is in the cookie httpOnly: the client never sees it;
  • Lines 31–44: [check()] asks the server who is logged in ONCE: the promise is kept and reused ([??=]: assignment if the variable is [null]). The guards call it before each navigation: only the first one actually waits;
  • lines 46–48: the connection: the user returned by the server is stored in the store;
  • lines 53–59: logout. [finally]: even if the server does not respond, the client forgets the user;
  • lines 66–72: what is inferred about the user: ordinary functions that take the user as a parameter. A component writes [isAdmin(user)] after reading [user] from the store; it is then redisplayed when the user changes.

10.4.5.2. src/stores/messages.ts

// =========================================================================
// stores/messages.ts - the messages displayed to the user
// -------------------------------------------------------------------------
// Same presentation as the HTML application:
//   - a SUCCESS is displayed at the TOP of the page (green banner);
//   - an ERROR at the BOTTOM, on a light pink background ("sticky" panel).
// A message is a translation KEY (+ its parameters): it is therefore
// translated again on its own if the user changes the language.
//
// The "flash" messages: an action that ends with a change of
// page (booking, adding a doctor...) leaves its message with flash();
// it will be displayed on the NEXT page, then disappear at the navigation
// after. It is the router that calls newPage() at the end of each
// navigation (see router.ts). That was, on the server side, the role of the
// short-lived cookie "rdvmedecins_flash".
// =========================================================================

import { create } from 'zustand';
import { ApiError } from '../api/api-error';

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

interface Messages {
  success: Message | null;
  error: Message | null;
  // the messages to display on the next page
  pending: { success: Message | null; error: Message | null } | null;
  showSuccess: (key: string, params?: Record<string, string>) => void;
  showError: (key: string, params?: Record<string, string>) => void;
  showApiError: (e: unknown) => void;
  clear: () => void;
  flash: (type: 'success' | 'error', key: string, params?: Record<string, string>) => void;
  newPage: () => void;
}

export const useMessages = create<Messages>()((set) => ({
  success: null,
  error: null,
  pending: null,

  showSuccess: (key, params) => set({ success: { key, params }, error: null }),
  showError: (key, params) => set({ error: { key, params }, success: null }),

  // an ApiError -> the message of the page; any other error is a
  // bug of the client, which is let through (browser console)
  showApiError(e) {
    if (!(e instanceof ApiError)) {
      throw e;
    }
    set({ error: { key: e.key, params: e.params }, success: null });
  },

  clear: () => set({ success: null, error: null }),

  // for the NEXT page
  flash: (type, key, params) =>
    set((m) => ({ pending: { success: null, error: null, ...m.pending, [type]: { key, params } } })),

  // a new page: the messages of the previous one disappear, the
  // flash messages appear
  newPage: () =>
    set((m) => ({ success: m.pending?.success ?? null, error: m.pending?.error ?? null, pending: null })),
}));

Messages displayed to the user: a success message at the top of the page, an error message at the bottom.

  • Lines 21–24: A message is a translation KEY and its parameters, not plain text: it automatically re-translates itself if the user changes languages;
  • lines 49–54: a [ApiError] becomes the page’s error message. Any other error is a client-side issue (a programming error): it is re-triggered so that it appears in the browser console;
  • lines 59–60: [flash] sets a message for the NEXT page. An action that ends with a page change (adding a doctor, followed by a return to the list) uses this: a message displayed immediately would disappear as the user navigates;
  • lines 64–65: called by the router at the end of each navigation: the pending messages become the page’s messages.

10.4.5.3. src/stores/confirmation.ts

// =========================================================================
// stores/confirmation.ts - "Are you sure?"
// -------------------------------------------------------------------------
// Taken from example 16, in the form of a store: confirm(key) opens
// the confirmation dialog (components/ConfirmDialog.tsx, placed once
// in the layout) and returns a promise resolved to true (Yes) or
// false (No). The message is a translation KEY: the dialog translates it
// into the current language.
//
//     if (await confirm('SCHEDULE.CANCEL_CONFIRM')) { ... }
// =========================================================================

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

interface Confirmation {
  messageKey: string | null; // null: dialog closed
  answer: (yes: boolean) => void;
}

// the function that will resolve the current promise (nothing to display: outside the state)
let respond: ((yes: boolean) => void) | null = null;

export const useConfirmation = create<Confirmation>()((set) => ({
  messageKey: null,
  answer(yes) {
    set({ messageKey: null });
    const r = respond;
    respond = null;
    r?.(yes);
  },
}));

// an ordinary function, usable everywhere
export function confirm(key: string): Promise<boolean> {
  respond?.(false); // a question still open? it counts as "no"
  // a new question: the messages of the previous action are outdated
  useMessages.getState().clear();
  useConfirmation.setState({ messageKey: key });
  return new Promise<boolean>((resolve) => {
    respond = resolve;
  });
}

The confirmation window from Example 16, implemented as a store and an ordinary function:

  • line 17: the question asked (a translation key), or [null]: the window ([components/ConfirmDialog.tsx]) is closed;
  • line 22: the function that will resolve the promise for the current question. Nothing to display: this is not a state, but a module variable;
  • lines 35–43: [confirm] opens the window and returns a promise, resolved to [true] (Yes) or [false] (No) by [answer]. A page simply writes [if (await confirm('SCHEDULE.CANCEL_CONFIRM')) { ... }]. No context is needed, as in Example 16: the store is accessible everywhere.

10.4.6. The hooks

10.4.6.1. src/hooks/useFormSubmit.ts

// =========================================================================
// hooks/useFormSubmit.ts - submitting a form to the server
// -------------------------------------------------------------------------
// All the forms of the application follow the same pattern as
// example 25: no rule is checked in the browser (they
// live in ONE place only, the server), and when the form is rejected:
//   - the error of each field (ApiError.fields) is displayed under the
//     field, framed in red;
//   - the general message (ApiError.key) is displayed at the bottom of the page.
// This hook groups these gestures. A page calls it once per form:
//
//     const submission = useFormSubmit();
//     if (await submission.submit(() => api.register(form))) { ... }
//     <InputField ... error={submission.errors.lastName} />
// =========================================================================

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

export function useFormSubmit() {
  const showApiError = useMessages((m) => m.showApiError);
  // the error of each field: a translation KEY
  const [errors, setErrors] = useState<Record<string, string>>({});
  // the parameters of the error messages ({min}...), returned by the server
  const [params, setParams] = useState<Record<string, string>>({});
  const [submitting, setSubmitting] = useState(false);

  // runs the submission; returns true if it succeeded. On an ApiError, the
  // errors are displayed and false is returned.
  const submit = useCallback(
    async (action: () => Promise<unknown>): Promise<boolean> => {
      setErrors({});
      setParams({});
      setSubmitting(true);
      try {
        await action();
        return true;
      } catch (e) {
        showApiError(e); // (rethrows any error that is not an ApiError)
        const error = e as ApiError;
        setErrors(error.fields);
        setParams(error.params);
        return false;
      } finally {
        setSubmitting(false);
      }
    },
    [showApiError],
  );

  // the user modifies a field: its error disappears
  const clearError = useCallback((field: string) => {
    setErrors((e) => {
      if (!(field in e)) return e; // nothing to clear: the SAME object (no new rendering)
      const { [field]: _cleared, ...rest } = e;
      return rest;
    });
  }, []);

  return { errors, params, submitting, submit, clearError };
}

All forms in the application follow the pattern shown in Example 25: no validation rules are checked in the browser, and if the server rejects the submission, the error for each field is displayed below the field, with a general error message at the bottom of the page. This custom hook (Example 15) handles the following actions:

  • lines 24–27: its state: the error for each field (a translation key), their parameters, and “submitting” (to disable the button);
  • lines 31–50: [submit(action)] executes the submission and returns [true] if it succeeds. If [ApiError] is returned, the errors are displayed and the function returns [false]. [useCallback]: the function remains the SAME from one display to the next;
  • Lines 53–59: The user modifies a field: the error disappears. If the field did not have an error, the SAME object is returned; [React] does not redisplay anything. [const { [field]: _cleared, ...rest } = e]: [rest] is a copy of [e], without the [field] property.

10.4.6.2. src/hooks/useCaptcha.ts

// =========================================================================
// hooks/useCaptcha.ts - a captcha image, and the means to change it
// -------------------------------------------------------------------------
// Loads a captcha (GET /api/captcha) when the component that uses it
// is displayed. reload() asks for another one: it is needed after EACH
// attempt, because an image can only be used ONCE (the server forgets it as soon
// as it has checked it). The login page uses it with the component
// CaptchaField, which displays the image and the input field.
// =========================================================================

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 reload = useCallback(async () => {
    try {
      setCaptcha(await api.captcha());
    } catch {
      setCaptcha(null); // server unreachable: the login will fail with a clear message
    }
  }, []);

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

  // captchaId: the identifier of the displayed image, to send with the answer
  return { svg: captcha?.svg ?? '', captchaId: captcha?.id ?? '', reload };
}

A CAPTCHA image, loaded when the component is displayed, and [reload()] to request another one: this is necessary after EACH attempt, because the server discards an image as soon as it has verified it.

10.4.7. The formatting

10.4.7.1. src/formatting.ts

// =========================================================================
// formatting.ts - the display of persons, times and days
// -------------------------------------------------------------------------
// The server sends RAW data (startHour: 8, startMinute: 0; day:
// "2026-10-05"); the formatting, which depends on the language, is the
// client's business (see example 09).
// =========================================================================

import type { Slot, Identity } from './api/types';

// "Mme Alice DURAND"
export function label(p: Identity): string {
  return `${p.title} ${p.firstName} ${p.lastName}`;
}

// "8:05" rather than "8:5"
export function time(h: number, m: number): string {
  return `${h}:${String(m).padStart(2, '0')}`;
}

// "8:00 - 8:20"
export function timeRange(s: Slot): string {
  return `${time(s.startHour, s.startMinute)} - ${time(s.endHour, s.endMinute)}`;
}

// today's date, in the "yyyy-MM-dd" format (that of <input type="date"> and of the API)
export function today(): string {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

// true if [day] is a date that exists, in the yyyy-MM-dd format
export function isValidDate(day: unknown): day is string {
  if (typeof day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(day)) {
    return false;
  }
  const date = new Date(`${day}T00:00:00Z`);
  return !isNaN(date.getTime()) && date.toISOString().slice(0, 10) === day;
}

// "lundi 5 octobre 2026" / "Monday 5 October 2026"
export function longDate(day: string, language: string): string {
  return new Intl.DateTimeFormat(language === 'en' ? 'en-GB' : 'fr-FR', {
    dateStyle: 'full',
    timeZone: 'UTC', // "2026-10-05T00:00:00Z" must stay the 5th, whatever the time zone
  }).format(new Date(`${day}T00:00:00Z`));
}

The server sends raw data ([startHour: 8, startMinute: 0]; [day: "2026-10-05"]); formatting is the client’s responsibility (example 09). These are ordinary functions: [React] does not require “pipes,” and an expression such as JSX can call any function ([{timeRange(c)}]).

  • Lines 33–39: [day is string]: a type predicate: if the function returns [true], [TypeScript] then knows that [day] is a string. A date “that exists”: [2026-02-30] has the correct format, but [new Date] converts it to March 2; the resulting date is then compared to the original string;
  • lines 42–47: the day spelled out in full, in the user’s language, using [Intl.DateTimeFormat]. [timeZone: 'UTC']: The date is constructed at midnight (UTC); formatted in the Martinique time zone (UTC-4), it would still be the previous day.

10.4.8. The router

10.4.8.1. src/routes.tsx

// =========================================================================
// routes.tsx - the pages of the application and their access rights
// -------------------------------------------------------------------------
// The SAME addresses as the HTML application (/login, /schedule, /doctors,
// /my-area...), but they are now routes of the CLIENT. The
// browser only sends a request to the server for the very first
// page; after that, the router changes the address and the displayed component.
//
// The flip side: if the user reloads /schedule (F5), the
// browser does request /schedule from the server, which must then
// return index.html (Vite does it in development; in production,
// see web/client-spa.ts of the JSON server).
//
// All the pages are CHILDREN of the root route, whose component,
// Layout, displays them in its <Outlet />. The page() function describes a
// page: its address, its component (loaded on demand for the pages
// that are not always visited), and the guard that checks its rights.
// =========================================================================

import type { ComponentType } from 'react';
import { redirect, type MiddlewareFunction, type RouteObject } from 'react-router';
import type { PersonType } from './api/rdvmedecins.api';
import { guard, registrationGuard, type Rights } from './guards';
import Layout from './components/Layout';
import Login from './pages/Login';
import Schedule from './pages/Schedule';
import ErrorPage from './pages/ErrorPage';

// a page: component loaded at once (Component) or on demand
// (load); otherGuards: guards specific to the page, after the common guard
interface Page {
  path: string;
  rights: Rights;
  Component?: ComponentType;
  load?: () => Promise<ComponentType>;
  otherGuards?: MiddlewareFunction[];
}

function page({ path, rights, Component, load, otherGuards = [] }: Page): RouteObject {
  return {
    path,
    // the guards run BEFORE the page is loaded
    middleware: [guard(rights), ...otherGuards],
    ...(load ? { lazy: async () => ({ Component: await load() }) } : { Component }),
  };
}

// the management pages of the doctors AND of the clients (ADMIN), on the same
// model; the component receives the type as a prop
function personPages(type: PersonType): RouteObject[] {
  const rights: Rights = { roles: ['ADMIN'] };
  const list = async () => {
    const { default: Persons } = await import('./pages/Persons');
    return () => <Persons type={type} />;
  };
  const form = async () => {
    const { default: PersonForm } = await import('./pages/PersonForm');
    return () => <PersonForm type={type} />;
  };
  return [
    page({ path: type, rights, load: list }),
    page({ path: `${type}/add`, rights, load: form }),
    page({ path: `${type}/:id/edit`, rights, load: form }),
  ];
}

export const routes: RouteObject[] = [
  {
    path: '/',
    Component: Layout,
    // displayed during the very first navigation (the time to ask the
    // server who is logged in)
    HydrateFallback: () => null,
    children: [
      // "/": to /login, which itself sends to the role's home page if the user is logged in
      // (a component that displays nothing: the redirect happens before)
      { index: true, loader: () => redirect('/login'), Component: () => null },
      page({ path: 'login', rights: { public: true }, Component: Login }),
      page({ path: 'schedule', rights: {}, Component: Schedule }),
      ...personPages('doctors'),
      ...personPages('clients'),
      page({
        path: 'register',
        rights: { public: true },
        load: async () => (await import('./pages/Register')).default,
        otherGuards: [registrationGuard],
      }),
      page({ path: 'my-area', rights: { roles: ['USER'] }, load: async () => (await import('./pages/PatientArea')).default }),
      page({ path: 'my-account', rights: { roles: ['USER'] }, load: async () => (await import('./pages/MyAccount')).default }),
      page({ path: 'forbidden', rights: {}, Component: () => <ErrorPage code={403} /> }),
      page({ path: '*', rights: { public: true }, Component: () => <ErrorPage code={404} /> }),
    ],
  },
];

The SAME addresses that the HTML application uses, but these are now routes from the CLIENT:

  • lines 31–37: the description of a page: its address, access permissions, and component, either provided immediately ([Component]) or loaded on demand ([load]);
  • lines 39–46: [page()] generates the corresponding route:
    • line 43: the common guard, generated based on the page’s permissions, followed by the page-specific guards. They run BEFORE the page is loaded (Example 18);
    • line 44: [lazy]: the page code is loaded on the first visit (Example 17). The login page and the calendar, which everyone can see, are loaded immediately; the ADMIN and patient pages are loaded on demand;
  • lines 50–65: the three pages for managing doctors, followed by clients, using the same template. The component is wrapped in a parameterless component, [() => <Persons type={type} />]: this is how it receives the type as a prop;
  • lines 69–70: the root route. Its component, [Layout], displays the current page in its [<Outlet />];
  • line 73: what is displayed during the very first navigation, while the guard asks the server which one is connected: nothing;
  • line 77: the address [/] redirects to [/login], whose guard itself redirects to the role’s home page if the user is logged in;
  • lines 78–79: [rights: { public: true }]: the page is accessible without being logged in; [rights: {}]: you must be logged in, regardless of your role;
  • lines 83–87: creating a public account has a second guard, [registrationGuard];
  • lines 88–89: [roles: ['USER']]: the patient’s pages;
  • line 90: the 403 page;
  • line 91: the 404 page: [*] refers to any address that no other route recognizes.

Reminder: If the user reloads [/schedule] (F5), the browser does indeed request [/schedule] from the server, which must then return [index.html]: this is what the development server for [Vite] does, and, in production, the server for JSON (see the “Deployment” chapter).

10.4.8.2. src/guards.ts

// =========================================================================
// guards.ts - the guards of the router (middlewares, see example 18)
// -------------------------------------------------------------------------
// guard(rights) CREATES the guard of a page, from its access rights:
//   - public : accessible without being logged in;
//   - roles  : the allowed roles (absent = all the logged-in roles).
// Each page declares its own (see routes.tsx). Before each navigation
// to this page, the guard:
//   1. the first time (startup, F5), asks the server who is
//      logged in (useSession.getState().check());
//   2. checks the rights of the page, and redirects if needed.
//
// REMINDER: these checks are only a CONVENIENCE (not displaying a useless
// page). The protection of the data is ensured by the server, which
// checks the token and the role again on EACH request.
// =========================================================================

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

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

export function guard(rights: Rights): MiddlewareFunction {
  return async ({ request }) => {
    await useSession.getState().check();
    // getState(): the state of the store, read OUTSIDE a component
    const user = useSession.getState().user;
    const url = new URL(request.url);

    if (rights.public) {
      // already logged in: the login page no longer makes sense
      if (url.pathname === '/login' && user !== null) {
        throw redirect(homePage(user));
      }
      return;
    }
    if (user === null) {
      // the requested page is remembered, to come back to it after logging in
      throw redirect(`/login?redirect=${encodeURIComponent(url.pathname + url.search)}`);
    }
    if (rights.roles && !rights.roles.includes(user.role)) {
      throw redirect('/forbidden');
    }
  };
}

// a guard SPECIFIC to the /register route: without a pass (captcha
// solved on the login page), back to the login page
export const registrationGuard: MiddlewareFunction = async () => {
  const { valid } = await api.registrationAccessStatus().catch(() => ({ valid: false }));
  if (!valid) {
    useMessages.getState().flash('error', 'ERRORS.CAPTCHA_BEFORE_ACCOUNT');
    throw redirect('/login');
  }
};
  • lines 24–27: a page’s access rights;
  • lines 29–51: [guard(rights)] CREATES a guard, i.e., router middleware (example 18):
    • line 31: the first time (startup, F5), the server is asked who is logged in; on subsequent occasions, the response is already available;
    • lines 36–42: a public page. A logged-in user no longer needs to do anything on the login page: they are redirected to their home page;
    • lines 43–46: no one is logged in: redirect to the login page, while retaining the requested page ([?redirect=...]), to return to it after login;
    • lines 47–49: the role is not authorized: the 403 page;
    • [throw redirect(...)]: a guard “triggers” a redirect: the current navigation is abandoned in favor of the new address;
  • lines 55–61: the guard specific to page [/register]: without a pass (the CAPTCHA solved on the login page), return to the login page with a message. The server would refuse to create the account in any case.

These checks are simply a CONVENIENCE: do not display a useless page. Data protection is ensured by the server, which re-verifies the token and role at EACH request.

10.4.9. The components

10.4.9.1. src/components/Layout.tsx

// =========================================================================
// components/Layout.tsx - the LAYOUT common to all the pages
// -------------------------------------------------------------------------
// The component of the root route (see routes.tsx), the equivalent of
// views/layout.hbs of the HTML application:
//   - the navigation bar;
//   - a success message at the TOP;
//   - the current page (<Outlet />);
//   - the error panel at the BOTTOM;
//   - the confirmation dialog, once for the whole application.
// =========================================================================

import { Outlet } from 'react-router';
import NavBar from './NavBar';
import SuccessMessage from './SuccessMessage';
import ErrorPanel from './ErrorPanel';
import ConfirmDialog from './ConfirmDialog';

export default function Layout() {
  return (
    <>
      <div className="container py-4">
        <NavBar />
        <SuccessMessage />
        <Outlet />
        <ErrorPanel />
      </div>
      <ConfirmDialog />
    </>
  );
}

The root route component, the equivalent of [views/layout.hbs] in the HTML application: the navigation bar, the success message at the top, the current page ([<Outlet />]), and the error area at the bottom. The confirmation window is placed there once for the entire application.

10.4.9.2. src/components/PageTitle.tsx

// =========================================================================
// components/PageTitle.tsx - the title of the tab, translated
// -------------------------------------------------------------------------
// React 19 places in the <head> of the page any <title> element rendered by
// a component (see example 17). Each page displays <PageTitle titleKey="..." />
// with the key of its title: the tab displays "Schedule - RdvMedecins", and
// is translated again when the language changes.
// =========================================================================

import { useTranslation } from 'react-i18next';

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

[React] 19 places in the page header ([<head>]) any [<title>] element rendered by a component (example 17). Each page displays [<PageTitle titleKey="SCHEDULE.PAGE_TITLE" />]: the tab displays “Schedule - RdvMedecins,” and is re-translated when the language changes.

10.4.9.3. src/components/NavBar.tsx

// =========================================================================
// components/NavBar.tsx - the navigation bar
// -------------------------------------------------------------------------
// Same content as that of the HTML application (views/partials/navbar.hbs):
// title, menus according to the role, FR / EN, role, name, logout.
// "Doctors" and "Patients" only appear for the ADMIN: a simple
// display convenience, the real protection being that of the server.
// =========================================================================

import { NavLink, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { LANGUAGES } from '../i18n/i18n';
import { isAdmin, isPatient, useSession } from '../stores/session';

export default function NavBar() {
  const { t, i18n } = useTranslation();
  const user = useSession((s) => s.user);
  const logOut = useSession((s) => s.logOut);
  const navigate = useNavigate();

  async function onLogOut() {
    await logOut().catch(() => {}); // even if the server does not respond
    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>

      {user ? (
        <ul className="navbar-nav me-auto">
          {isPatient(user) && (
            <li className="nav-item">
              {/* NavLink sets the "active" class (Bootstrap) on the menu of the displayed page */}
              <NavLink className="nav-link" to="/my-area">{t('APP.MENU_MY_AREA')}</NavLink>
            </li>
          )}
          <li className="nav-item">
            <NavLink className="nav-link" to="/schedule">{t('APP.MENU_SCHEDULE')}</NavLink>
          </li>
          {isAdmin(user) && (
            <>
              <li className="nav-item">
                <NavLink className="nav-link" to="/doctors">{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: no request to the server, the page is translated again in place */}
        <div className="btn-group btn-group-sm" role="group" aria-label={t('APP.LANGUAGE')}>
          {LANGUAGES.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>

        {user && (
          <>
            <span className="badge text-bg-light text-primary">{user.role}</span>
            <span className="text-white">{user.name}</span>
            <button type="button" className="btn btn-sm btn-outline-light" onClick={onLogOut}>{t('APP.LOGOUT')}</button>
          </>
        )}
      </div>
    </nav>
  );
}
  • lines 17–18: the component reads the user and the logout function from the session store; it is re-displayed when the user changes (login, logout, session expired);
  • lines 21–24: logout, then the login page. Even if the server does not respond, the client forgets the user (see [stores/session.ts]): the error is ignored ([catch(() => {})]);
  • lines 32–37: the menus depend on the role;
  • line 35: [NavLink] (example 17) sets the [active] class from [Bootstrap] on the menu of the displayed page;
  • lines 59–64: the FR / EN buttons. [i18n.changeLanguage(l)]: no request is sent to the server; the entire page is re-rendered locally. [aria-pressed]: a screen reader announces the current language button as “pressed”;
  • lines 67–73: the role, the name, and the logout button.

10.4.9.4. src/components/SuccessMessage.tsx and ErrorPanel.tsx

// =========================================================================
// components/SuccessMessage.tsx - the SUCCESS message, at the top of the page
// (appointment saved, doctor added...) - see stores/messages.ts.
// role="status": a screen reader announces it without interrupting the user.
// =========================================================================

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

export default function SuccessMessage() {
  const { t } = useTranslation();
  const success = useMessages((m) => m.success);
  return success && <div className="alert alert-success" role="status">{t(success.key, success.params)}</div>;
}
// =========================================================================
// components/ErrorPanel.tsx - the error panel, at the BOTTOM of each page, on
// a light pink background (class .error-panel, see styles.css) - like
// views/partials/errors.hbs of the HTML application.
// role="alert": a screen reader announces the message as soon as it appears.
// =========================================================================

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

export default function ErrorPanel() {
  const { t } = useTranslation();
  const error = useMessages((m) => m.error);
  return error && <div className="error-panel" role="alert">{t(error.key, error.params)}</div>;
}

Two small components that display messages from the [useMessages] store, translated into the current language. [role="status"] and [role="alert"]: A screen reader announces the message as soon as it appears—discreetly for a success, immediately for an error. A component may return [null] (or [false]): in that case, it displays nothing.

10.4.9.5. src/components/ConfirmDialog.tsx

// =========================================================================
// components/ConfirmDialog.tsx
// -------------------------------------------------------------------------
// The "Are you sure?" dialog of example 16, completed for the keyboard
// like public/js/app.js of the HTML application:
//   - on opening, the focus goes to "No, go back": an absent-minded press on
//     Enter deletes nothing;
//   - Escape closes the dialog (= No);
//   - Tab and Shift+Tab stay INSIDE the dialog (otherwise the focus would go to
//     the greyed-out page, behind);
//   - on closing, the focus goes back to the element that had it (the
//     "Delete" button clicked, for example).
// "No, go back" rather than "Cancel": the question is sometimes "Cancel this
// appointment?", and a "Cancel" button would be ambiguous there.
// The component is placed once in the layout; it displays nothing as long
// as the confirmation store has no question (see 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 ConfirmDialog() {
  const { t } = useTranslation();
  const key = useConfirmation((c) => c.messageKey);
  const answer = useConfirmation((c) => c.answer);
  const dialog = useRef<HTMLDivElement>(null);
  const noButton = useRef<HTMLButtonElement>(null);

  // the dialog opens: we remember the element that has the focus and give it
  // to the "No" button; it closes (cleanup): we give the focus back
  const open = key !== null;
  useEffect(() => {
    if (!open) return;
    const previousFocus = document.activeElement as HTMLElement | null;
    noButton.current?.focus();
    return () => previousFocus?.focus();
  }, [open]);

  if (key === null) {
    return null; // dialog closed: nothing to display
  }

  function onKeydown(e: KeyboardEvent<HTMLDivElement>) {
    if (e.key === 'Escape') {
      answer(false);
    } else if (e.key === 'Tab' && dialog.current) {
      const buttons = [...dialog.current.querySelectorAll('button')];
      const first = buttons[0];
      const last = buttons[buttons.length - 1];
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last?.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first?.focus();
      }
    }
  }

  // a click on the greyed-out backdrop (and not in the dialog itself) = No
  function onBackdropClick(e: MouseEvent<HTMLDivElement>) {
    if (e.target === e.currentTarget) {
      answer(false);
    }
  }

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

The window from Example 16, linked to the confirmation store and completed for the keyboard:

  • lines 26–27: the question asked ([null]: window closed) and the function that answers it;
  • lines 33–39: an effect that depends on [open]: upon opening, the element that has focus (the “Delete” button that was clicked) is retained, and focus is given to the “No, go back” button: an accidental press of Enter does not delete anything. Cleaning up the effect, performed upon closing, returns focus to the retained element;
  • lines 41–43: window closed: nothing to display. The hooks are called BEFORE this [return]: they must be called each time the window is displayed, in the same order;
  • lines 45–60: Esc means “No.” Tab and Shift+Tab keep the focus INSIDE the window: Tab moves from the last button to the first, and vice versa; otherwise, the focus would move to the grayed-out page behind it;
  • lines 63–67: A click on the grayed-out background is treated as “No.” [e.target] is the clicked element, and [e.currentTarget] is the one that holds the handler: they are equal if the click occurred on the background itself, and not within the window;
  • Lines 69–94: [createPortal] (Example 16) displays the window at the end of [<body>], outside the page: no page style can distort it;
  • line 72: for a screen reader, a window requesting an urgent response; [aria-labelledby] and [aria-describedby] refer to its title and question.

10.4.9.6. src/components/InputField.tsx

// =========================================================================
// components/InputField.tsx - a complete form field
// -------------------------------------------------------------------------
// Label + field + help message OR error message. All the
// forms of the application use it:
//
//   <InputField fieldId="lastName" label="PERSONS.LASTNAME_LABEL" maxLength={20}
//               value={form.lastName} onChange={(v) => edit('lastName', v)} error={submission.errors.lastName} />
//
// It is a CONTROLLED component (see example 11): the value belongs to the
// parent form. The error is the one returned by the SERVER (see
// hooks/useFormSubmit.ts). label, help and error are translation
// KEYS; params, the parameters of their messages ({min}...).
// =========================================================================

import { useTranslation } from 'react-i18next';

interface Props {
  fieldId: string; // the id of the <input>, linked to the <label>
  label: string;
  value: string;
  onChange: (value: string) => void;
  error?: string;
  help?: string;
  params?: Record<string, string>;
  type?: 'text' | 'password';
  maxLength?: number;
  autoComplete?: string;
  required?: boolean;
  autoFocus?: boolean;
  className?: string; // the classes of the block (mb-3, col-sm-6...)
}

export default function InputField({
  fieldId, label, value, onChange, error, help, params = {}, type = 'text', maxLength,
  autoComplete = 'off', required = false, autoFocus = false, className = '',
}: Props) {
  const { t } = useTranslation();
  const messageId = `${fieldId}-message`;

  return (
    <div className={className}>
      <label className="form-label" htmlFor={fieldId}>{t(label)}</label>
      <input id={fieldId} type={type} className={`form-control ${error ? 'is-invalid' : ''}`} value={value}
             onChange={(e) => onChange(e.target.value)} maxLength={maxLength} autoComplete={autoComplete}
             required={required} autoFocus={autoFocus} aria-invalid={error ? true : undefined} aria-describedby={messageId} />
      {error ? (
        <div className="invalid-feedback" id={messageId}>{t(error, params)}</div>
      ) : (
        help && <div className="form-text" id={messageId}>{t(help, params)}</div>
      )}
    </div>
  );
}

A complete form field: label, field, help message OR error message. All forms in the application use it.

  • lines 18–32: its props; [label], [help], and [error] are translation props (KEYS);
  • lines 34–37: the unpacking of the props, with default values ([type = 'text']);
  • line 39: the identifier for the help or error message, which [aria-describedby] links to the field; a screen reader reads it along with the field;
  • lines 44–46: a CONTROLLED field (example 05): its value belongs to the parent form, which receives it via [onChange]. [is-invalid]: the red box from [Bootstrap];
  • lines 47–51: the error returned by the server, otherwise the help text.

10.4.9.7. src/components/PersonFields.tsx

// =========================================================================
// components/PersonFields.tsx - title, first name, last name
// -------------------------------------------------------------------------
// The identity fields, common to three forms: the ADMIN's doctor / client
// form, "Create a patient account" and "Manage my account" (the equivalent of the
// partial views/partials/personFields.hbs). The parent passes it the
// values, the errors, and the function that modifies a field:
//     <PersonFields values={form} errors={submission.errors} onChange={edit} />
// =========================================================================

import { useTranslation } from 'react-i18next';
import type { Identity } from '../api/types';
import InputField from './InputField';

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

interface Props {
  values: Identity;
  errors: Record<string, string>;
  onChange: (field: keyof Identity, value: string) => void;
}

export default function PersonFields({ values, errors, onChange }: Props) {
  const { t } = useTranslation();
  return (
    <>
      <div className="mb-3">
        <label className="form-label" htmlFor="title">{t('PERSONS.TITLE_LABEL')}</label>
        <select id="title" className={`form-select ${errors.title ? 'is-invalid' : ''}`} value={values.title}
                onChange={(e) => onChange('title', e.target.value)}>
          {TITLES.map((title) => <option key={title}>{title}</option>)}
        </select>
        {errors.title && <div className="invalid-feedback">{t(errors.title)}</div>}
      </div>
      <div className="row g-3 mb-3">
        <InputField className="col-sm-6" fieldId="firstName" label="PERSONS.FIRSTNAME_LABEL" maxLength={20} required
                    value={values.firstName} onChange={(v) => onChange('firstName', v)} error={errors.firstName} />
        <InputField className="col-sm-6" fieldId="lastName" label="PERSONS.LASTNAME_LABEL" maxLength={20} required
                    value={values.lastName} onChange={(v) => onChange('lastName', v)} error={errors.lastName} />
      </div>
    </>
  );
}

Title, first name, and last name: fields common to three forms (ADMIN doctor or client, account creation, account management). The equivalent of the partial [views/partials/personFields.hbs] from the HTML application.

  • Line 20: [keyof Identity]: type ['title' | 'firstName' | 'lastName']; the compiler rejects any other field name;
  • line 31: a [<option>] without a [value] has its text as its value.

10.4.9.8. src/components/CaptchaField.tsx

// =========================================================================
// components/CaptchaField.tsx - the captcha image and the input field
// -------------------------------------------------------------------------
// A display component: the image (svg), the "Another image" link
// (onReload), and the field, CONTROLLED by the parent form (value,
// onChange). The image and its identifier come from the useCaptcha hook, which
// the parent calls (see pages/Login.tsx): it is the parent that needs them
// to send the answer.
//
// dangerouslySetInnerHTML: the SVG image is inserted as is. Without
// danger HERE because it is created by our server (library
// svg-captcha) and contains nothing that comes from a user - see
// example 22.
// =========================================================================

import { useTranslation } from 'react-i18next';

interface Props {
  svg: string;
  value: string;
  onChange: (value: string) => void;
  onReload: () => void;
  error?: string;
}

export default function CaptchaField({ svg, value, onChange, onReload, error }: 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={onReload}>{t('CAPTCHA.NEW_IMAGE')}</button>
      </div>
      <input id="captcha" type="text" maxLength={10} autoComplete="off" autoCapitalize="off" spellCheck={false}
             className={`form-control ${error ? 'is-invalid' : ''}`} value={value} onChange={(e) => onChange(e.target.value)} />
      {error ? <div className="invalid-feedback">{t(error)}</div> : <div className="form-text">{t('CAPTCHA.HELP')}</div>}
    </div>
  );
}

A display component: the image, the “Another image” button, and the field, controlled by the parent form. The image and its identifier come from the [useCaptcha] hook, which the page calls: the page needs it to send the response.

  • Line 32: The image SVG is inserted as-is (Example 22). Safe HERE: It is generated by our server and contains nothing provided by a user. [role="img"] and [aria-label]: For a screen reader, this is an image with its description;
  • line 35: [autoCapitalize="off"] and [spellCheck={false}]: no automatic capitalization (phones) or spell check.

10.4.9.9. src/components/BookingDialog.tsx

// =========================================================================
// components/BookingDialog.tsx - the booking dialog of a slot
// -------------------------------------------------------------------------
// Same dialog as views/partials/booking.hbs of the HTML application:
// the Bootstrap classes of a modal dialog, displayed "hard-coded" (show
// d-block).
//
// The component does not talk to the server: it calls
//   - onConfirm, with the chosen patient (null for a patient who books
//     for themselves);
//   - onClose.
// It is the schedule page that decides what to do with it (pages/Schedule.tsx).
// =========================================================================

import { useEffect, useState, type FormEvent, type MouseEvent } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { Person } from '../api/types';
import { label } from '../formatting';

interface Props {
  time: string; // "8:00 - 8:20"
  clients: readonly Person[]; // ADMIN / DOCTOR: the patients to choose from
  patient: string | null; // USER: the patient is the user themselves
  submitting: boolean;
  onConfirm: (clientId: number | null) => void;
  onClose: () => void;
}

export default function BookingDialog({ time, clients, patient, submitting, onConfirm, onClose }: Props) {
  const { t } = useTranslation();
  const [clientId, setClientId] = useState<number | null>(null);

  // Escape, anywhere in the page: we close
  useEffect(() => {
    const onKeydown = (e: globalThis.KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    document.addEventListener('keydown', onKeydown);
    return () => document.removeEventListener('keydown', onKeydown);
  }, [onClose]);

  function confirm(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    onConfirm(clientId);
  }

  // a click on the greyed-out backdrop (and not in the dialog itself) closes the dialog
  function onBackdropClick(e: MouseEvent<HTMLDivElement>) {
    if (e.target === e.currentTarget) {
      onClose();
    }
  }

  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="booking-title"
           onClick={onBackdropClick}>
        <div className="modal-dialog modal-dialog-centered">
          <form className="modal-content" onSubmit={confirm}>
            <div className="modal-header">
              <h5 id="booking-title" className="modal-title">{t('BOOKING_DIALOG.TITLE')}</h5>
              <button type="button" className="btn-close" aria-label={t('BOOKING_DIALOG.CLOSE_ARIA')} onClick={onClose}></button>
            </div>

            <div className="modal-body">
              <p>{t('BOOKING_DIALOG.SLOT', { time })}</p>
              {/* a patient books for themselves: no list (and the server
                  would ignore a possible clientId anyway) */}
              {patient !== null ? (
                <p className="mb-0">{t('BOOKING_DIALOG.PATIENT_LABEL')}: <strong>{patient}</strong></p>
              ) : (
                <>
                  <label className="form-label" htmlFor="clientId">{t('BOOKING_DIALOG.PATIENT_LABEL')}</label>
                  {/* autoFocus: when displayed, the focus is on the list */}
                  <select id="clientId" className="form-select" autoFocus value={clientId ?? ''}
                          onChange={(e) => setClientId(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}>{label(c)}</option>)}
                  </select>
                </>
              )}
            </div>

            <div className="modal-footer">
              <button type="button" className="btn btn-secondary" onClick={onClose}>{t('BOOKING_DIALOG.CANCEL')}</button>
              {/* without a list, it is the confirmation button that has the focus */}
              <button type="submit" className="btn btn-primary" autoFocus={patient !== null} disabled={submitting}>
                {t('BOOKING_DIALOG.CONFIRM')}
              </button>
            </div>
          </form>
        </div>
      </div>
    </>,
    document.body,
  );
}

The appointment booking window. It does not communicate with the server: it calls [onConfirm] with the selected patient, or [onClose]; the calendar page decides what to do with it.

  • Lines 21–28: [clients]: the list of patients, for ADMIN and the doctors; [patient]: the name of the patient making the appointment for themselves ([null] for ADMIN and the doctors);
  • line 32: the patient selected from the list;
  • lines 35–41: Esc, anywhere on the page, closes the window. The effect subscribes to the document’s [keydown] event, and its cleanup unsubscribes upon closing. [globalThis.KeyboardEvent]: the browser event, not the one from [React], which has the same name;
  • lines 43–46: [preventDefault()] prevents the browser from submitting the form;
  • lines 71–83: A patient is making a reservation for themselves: no list. The server would ignore any [clientId] in any case;
  • line 78: the value of a [<option>] is always a string: it is converted to a number;
  • line 89: without a list, the confirmation button receives focus.

10.4.10. The pages

10.4.10.1. src/pages/Login.tsx

// =========================================================================
// pages/Login.tsx - the login page (/login)
// -------------------------------------------------------------------------
// Same page as views/login.hbs of the HTML application: login, password,
// captcha, and a second button "Create a patient account" which checks
// the SAME captcha before opening the account creation.
//
// /login?login=jbernard: the login field is pre-filled (right after the
// creation of an account).
// /login?redirect=/doctors: after logging in, this page is opened
// (left by the router's guard) rather than the role's home page.
// =========================================================================

import { useState, type FormEvent } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import { api } from '../api/rdvmedecins.api';
import { homePage, useSession } from '../stores/session';
import { useMessages } from '../stores/messages';
import { useFormSubmit } from '../hooks/useFormSubmit';
import { useCaptcha } from '../hooks/useCaptcha';
import CaptchaField from '../components/CaptchaField';
import PageTitle from '../components/PageTitle';

export default function Login() {
  const { t } = useTranslation();
  const [params] = useSearchParams();
  const navigate = useNavigate();
  const logIn = useSession((s) => s.logIn);
  const clearMessages = useMessages((m) => m.clear);
  const submission = useFormSubmit();
  const captcha = useCaptcha();

  // the login of the URL, if there is one: the INITIAL value of the field
  const urlLogin = (params.get('login') ?? '').slice(0, 30);
  const [form, setForm] = useState({ login: urlLogin, password: '', captcha: '' });

  function edit(field: keyof typeof form, value: string) {
    setForm((f) => ({ ...f, [field]: value }));
    submission.clearError(field);
  }

  // the two buttons share this sequence: submission, then, on failure,
  // error message and NEW image (each one can only be used once)
  async function attempt(action: () => Promise<unknown>): Promise<void> {
    clearMessages();
    const succeeded = await submission.submit(action);
    if (!succeeded) {
      setForm((f) => ({ ...f, captcha: '' }));
      await captcha.reload();
    }
  }

  async function onLogIn(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    await attempt(async () => {
      await logIn(form.login.trim(), form.password, captcha.captchaId, form.captcha);
      const redirect = params.get('redirect');
      // only an INTERNAL path: never a redirect to another site
      const internal = redirect !== null && redirect.startsWith('/') && !redirect.startsWith('//');
      await navigate(internal ? redirect : homePage(useSession.getState().user));
    });
  }

  async function createAccount() {
    await attempt(async () => {
      await api.registrationAccess(captcha.captchaId, form.captcha);
      await navigate('/register');
    });
  }

  return (
    <div className="row justify-content-center">
      <PageTitle titleKey="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={onLogIn}>
            <div className="mb-3">
              <label className="form-label" htmlFor="login">{t('LOGIN.LOGIN_LABEL')}</label>
              {/* required: the browser refuses to submit the form as long as the field is empty.
                  autoFocus: the focus on the login... or on the password if the login is already filled in */}
              <input id="login" type="text" className="form-control" autoComplete="username" required
                     autoFocus={urlLogin === ''} value={form.login} onChange={(e) => edit('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={urlLogin !== ''} value={form.password} onChange={(e) => edit('password', e.target.value)} />
            </div>

            <CaptchaField svg={captcha.svg} value={form.captcha} onChange={(v) => edit('captcha', v)}
                          onReload={captcha.reload} error={submission.errors.captcha} />

            {/* type="submit": Enter in a field triggers "Log in" */}
            <button type="submit" className="btn btn-primary w-100" disabled={submission.submitting}>{t('LOGIN.SUBMIT')}</button>

            {/* type="button": this button does not submit the form (and therefore
                ignores the required fields, useless here) */}
            <button type="button" className="btn btn-outline-primary w-100 mt-3" disabled={submission.submitting} onClick={createAccount}>
              {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>
  );
}

The [views/login.hbs] page of the HTML application: login, password, CAPTCHA, and a second "Create a patient account" button that verifies the SAME CAPTCHA before allowing the account creation process to begin.

  • Lines 29–32: What the page needs: login (session storage), clearing messages, submitting the form, and the CAPTCHA (our two hooks);
  • Lines 35–36: [/login?login=jbernard]: Immediately after creating an account, the username is pre-filled. [slice(0, 30)]: We do not retrieve more than the maximum length of a username. The URL only provides the INITIAL value of the field;
  • lines 38–41: when a field is entered, its new value appears and any error message disappears. [[field]: value]: a property whose name is calculated;
  • lines 45–52: both buttons follow the same sequence: submit, then, if that fails, clear the CAPTCHA and load a NEW image; each is used only once;
  • lines 54–63: login, then the next page:
    • line 58: the page requested before login, cached by the router ([/login?redirect=/doctors]);
    • line 60: ONLY an internal path: [/doctors], but not [https://malicious-site.com] or [//malicious-site.com] (a “protocol-less” address, which points to another site). Otherwise, a malicious link such as [/login?redirect=https://...] would send the user—who has just logged in—to another site: this is the so-called “open redirection” vulnerability;
    • line 61: otherwise, the role’s home page. [useSession.getState()]: the user has JUST been stored in the store; a component’s variable, however, will not change until the next page load;
  • lines 65–70: the second button: the CAPTCHA is verified by the server, which returns a token (a cookie) for the page [/register];
  • lines 84–85: [required]: the browser refuses to submit the form as long as the field is empty. This is the only validation performed in the browser—it’s simply a convenience feature. [autoFocus]: Focus is on the username field, or on the password field if the username is already filled in (line 90);
  • line 97: [type="submit"]: Typing in a field triggers the “Log in” button;
  • line 101: [type="button"]: this button does not submit the form and therefore ignores the [required] fields, which are unnecessary here.

10.4.10.2. src/pages/Schedule.tsx

The most feature-rich page in the application. As in the HTML application, ALL its state is in the URL:

/schedule?doctorId=1&day=2026-10-05              the schedule
/schedule?doctorId=1&day=2026-10-05&book=7       + the booking dialog

The page FOLLOWS the URL: it reads its parameters and uses them to determine what to display; user actions, in turn, modify URL. This allows you to reload the page, bookmark it, and use the browser’s Back and Forward buttons.

// =========================================================================
// pages/Schedule.tsx - the schedule page (/schedule)
// -------------------------------------------------------------------------
// Three blocks, like views/schedule.hbs of the HTML application:
//   1. the choice of the doctor and of the day;
//   2. the schedule of this doctor on that day;
//   3. the booking dialog of a slot.
//
// As in the HTML application, ALL the state of the page is in its URL:
//     /schedule?doctorId=1&day=2026-10-05             the schedule
//     /schedule?doctorId=1&day=2026-10-05&book=7      + the booking dialog
// The page can therefore be reloaded, bookmarked, and the browser's Back /
// Forward buttons work. The page FOLLOWS its URL: it reads its
// three parameters (useSearchParams) and deduces from them what it displays;
// the schedule is a TanStack Query query whose key contains the doctor
// and the day (see example 24); the user's actions, on the other hand,
// change the URL (navigate).
//
// What each user sees and can do is decided by the SERVER (fields
// patient, mine, bookable, appointmentId of each slot): the page just
// displays it. A patient does not even receive the names of the other 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 { ApiError } from '../api/api-error';
import { api } from '../api/rdvmedecins.api';
import type { ScheduleSlot, Person } from '../api/types';
import { today, isValidDate, timeRange, longDate, label } from '../formatting';
import { isManager, isPatient, useSession } from '../stores/session';
import { useMessages } from '../stores/messages';
import { confirm } from '../stores/confirmation';
import BookingDialog from '../components/BookingDialog';
import PageTitle from '../components/PageTitle';

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

// the address of the schedule (without the booking dialog, or with it)
function scheduleUrl(doctorId: number | null, day: string, book?: number): string {
  const params = new URLSearchParams({ doctorId: String(doctorId ?? ''), day });
  if (book !== undefined) {
    params.set('book', String(book));
  }
  return `/schedule?${params}`;
}

// -------------------------------------------------------------------------
// 1. the choice of the doctor and of the day: a small form, whose state is
// only used before submitting. It STARTS with the values of the URL; the page gives
// it a key that changes with them: React then recreates it, and it
// starts again from the new values (Back button...).
// -------------------------------------------------------------------------
interface PickerProps {
  doctors: Person[];
  doctorId: number | null;
  day: string;
  onView: (doctorId: number | null, day: string) => void;
}

function DoctorDayPicker({ doctors, doctorId, day, onView }: PickerProps) {
  const { t } = useTranslation();
  const [chosenDoctor, setChosenDoctor] = useState(doctorId);
  const [chosenDay, setChosenDay] = useState(day);

  function view(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    onView(chosenDoctor, chosenDay);
  }

  return (
    <form className="card p-3 mb-3" onSubmit={view}>
      <div className="row g-2 align-items-end">
        <div className="col-sm-5">
          <label className="form-label" htmlFor="doctorId">{t('DOCTOR_DAY_PICKER.DOCTOR_LABEL')}</label>
          <select id="doctorId" className="form-select" required value={chosenDoctor ?? ''}
                  onChange={(e) => setChosenDoctor(positiveInteger(e.target.value))}>
            <option value="">{t('DOCTOR_DAY_PICKER.CHOOSE_DOCTOR')}</option>
            {doctors.map((d) => <option key={d.id} value={d.id}>{label(d)}</option>)}
          </select>
        </div>
        <div className="col-sm-4">
          <label className="form-label" htmlFor="day">{t('DOCTOR_DAY_PICKER.DAY_LABEL')}</label>
          <input id="day" type="date" className="form-control" required value={chosenDay}
                 onChange={(e) => setChosenDay(e.target.value)} />
        </div>
        <div className="col-sm-3">
          <button type="submit" className="btn btn-primary w-100">{t('DOCTOR_DAY_PICKER.VIEW_SCHEDULE')}</button>
        </div>
      </div>
    </form>
  );
}

// -------------------------------------------------------------------------
// the page
// -------------------------------------------------------------------------
export default function Schedule() {
  const { t, i18n } = useTranslation();
  const navigate = useNavigate();
  const queryClient = useQueryClient();
  const user = useSession((s) => s.user);
  // the actions of the messages store: functions that never
  // change; getState() gives them without subscribing the page to the messages
  const { showSuccess, showError, showApiError, flash } = useMessages.getState();
  const [submitting, setSubmitting] = useState(false);

  // the parameters of the URL
  const [params] = useSearchParams();
  const urlDoctorId = positiveInteger(params.get('doctorId'));
  const urlDay = params.get('day');
  const bookId = positiveInteger(params.get('book'));
  // the day: the one of the URL if it is valid, otherwise today
  const invalidDay = urlDay !== null && !isValidDate(urlDay);
  const day = urlDay !== null && !invalidDay ? urlDay : today();

  // the lists: the doctors for everyone; the patients only for those
  // who are allowed to read them (ADMIN, DOCTOR: booking dialog)
  const doctors = useQuery({ queryKey: ['doctors'], queryFn: () => api.list('doctors') });
  const clients = useQuery({
    queryKey: ['clients'],
    queryFn: () => api.list('clients'),
    enabled: isManager(user),
  });

  // the schedule: without a doctor in the URL, a DOCTOR sees THEIR schedule (the
  // server finds it from the token); the others see nothing
  const withSchedule = !invalidDay && (urlDoctorId !== null || user?.role === 'DOCTOR');
  const schedule = useQuery({
    queryKey: ['schedule', urlDoctorId, day],
    queryFn: () => api.schedule(urlDoctorId, day),
    enabled: withSchedule,
  });
  const s = withSchedule ? schedule.data : undefined;
  // the displayed doctor: the one of the URL, or the one of the received schedule (DOCTOR)
  const doctorId = urlDoctorId ?? s?.doctor.id ?? null;

  // an invalid date in the URL: a message (an EFFECT: it acts on the
  // messages store, not on the rendering of this component)
  useEffect(() => {
    if (invalidDay) {
      showError('ERRORS.INVALID_DATE');
    }
  }, [invalidDay, showError]);

  // the slot whose booking dialog is open (?book=7),
  // only if it is bookable by this user
  const slotToBook = s?.slots.find((c) => c.id === bookId && c.bookable) ?? null;
  // the name of the logged-in patient (USER), or null for ADMIN and DOCTOR
  const patient = isPatient(user) ? (user?.name ?? '') : null;

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

  // "View schedule": we change the URL, the page follows
  function viewSchedule(chosenDoctorId: number | null, chosenDay: string) {
    navigate(scheduleUrl(chosenDoctorId, chosenDay));
  }

  // useCallback: the SAME function from one rendering to the next (the booking
  // dialog uses it in the dependencies of an effect)
  const closeBooking = useCallback(() => navigate(scheduleUrl(doctorId, day)), [navigate, doctorId, day]);

  // the server is the reference: after each action, the schedule is read again
  // (and the patient's area, which shows their appointment)
  const reload = () =>
    Promise.all([queryClient.invalidateQueries({ queryKey: ['schedule'] }), queryClient.invalidateQueries({ queryKey: ['my-area'] })]);

  async function confirmBooking(clientId: number | null) {
    if (slotToBook === null) {
      return;
    }
    setSubmitting(true);
    try {
      await api.book(day, slotToBook.id, isManager(user) ? clientId : null);
      flash('success', 'SCHEDULE.BOOKED');
    } catch (e) {
      if (e instanceof ApiError && e.key === 'ERRORS.CHOOSE_PATIENT') {
        // no patient chosen: the dialog stays open
        showError(e.key);
        return; // (finally runs anyway)
      }
      if (!(e instanceof ApiError)) {
        throw e;
      }
      // slot taken in the meantime, patient who already has an appointment...:
      // the message will be displayed on the schedule, dialog closed
      flash('error', e.key, e.params);
    } finally {
      setSubmitting(false);
    }
    await closeBooking();
    await reload();
  }

  async function cancel(c: ScheduleSlot) {
    if (c.appointmentId === null || !(await confirm('SCHEDULE.CANCEL_CONFIRM'))) {
      return;
    }
    try {
      await api.cancelAppointment(c.appointmentId);
      showSuccess('SCHEDULE.CANCELLED');
    } catch (e) {
      showApiError(e);
    }
    await reload();
  }

  // -----------------------------------------------------------------------
  // rendering
  // -----------------------------------------------------------------------
  let content;
  if (schedule.isLoading && !s) {
    content = <p className="text-muted">{t('APP.LOADING')}</p>;
  } else if (s) {
    content = (
      <div className="card p-3">
        <h5>{t('SCHEDULE.TITLE_PREFIX')} {longDate(s.day, i18n.language)} - {label(s.doctor)}</h5>
        {s.slots.length > 0 ? (
          <table className="table table-hover align-middle">
            <thead>
              <tr>
                <th>{t('SCHEDULE.COLUMN_SLOT')}</th>
                <th>{t('SCHEDULE.COLUMN_STATUS')}</th>
                <th></th>
              </tr>
            </thead>
            <tbody>
              {s.slots.map((c) => (
                <tr key={c.id} className={c.bookable ? 'free-slot' : ''}>
                  <td>{timeRange(c)}</td>
                  <td>
                    {c.free ? (
                      <span className="badge text-bg-success">{t('SCHEDULE.FREE')}</span>
                    ) : c.patient ? (
                      <span className={`badge ${c.mine ? 'text-bg-primary' : 'text-bg-secondary'}`}>{label(c.patient)}</span>
                    ) : (
                      <span className="badge text-bg-secondary">{t('SCHEDULE.BOOKED_BY_OTHER')}</span>
                    )}
                  </td>
                  <td className="text-end">
                    {/* "Book" is a LINK: it adds &book=... to the URL, which
                        opens the booking dialog */}
                    {c.bookable && (
                      <Link className="btn btn-sm btn-outline-success" to={scheduleUrl(doctorId, day, c.id)}>
                        {t('SCHEDULE.BOOK')}
                      </Link>
                    )}
                    {c.appointmentId !== null && (
                      <button type="button" className="btn btn-sm btn-outline-danger" onClick={() => cancel(c)}>
                        {t('SCHEDULE.CANCEL_APPOINTMENT')}
                      </button>
                    )}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        ) : (
          <p className="text-muted mb-0">{t('SCHEDULE.NO_SLOTS')}</p>
        )}
      </div>
    );
  } else {
    content = <p className="text-muted">{t('SCHEDULE.EMPTY_STATE')}</p>;
  }

  return (
    <>
      <PageTitle titleKey="SCHEDULE.PAGE_TITLE" />
      <DoctorDayPicker key={`${doctorId}|${day}`} doctors={doctors.data ?? []} doctorId={doctorId} day={day}
                       onView={viewSchedule} />

      {/* patient who already has an upcoming appointment: why there is no "Book" button */}
      {s?.info && (
        <div className="alert alert-info">
          {t(s.info)} <Link to="/my-area" className="alert-link">{t('APP.MENU_MY_AREA')}</Link>
        </div>
      )}

      {content}

      {/* 3. the booking dialog, only if the URL contains &book=... */}
      {slotToBook && (
        <BookingDialog time={timeRange(slotToBook)} clients={clients.data ?? []} patient={patient}
                       submitting={submitting} onConfirm={confirmBooking} onClose={closeBooking} />
      )}
    </>
  );
}
  • lines 39–42: one parameter of URL is a string entered by anyone; only a positive integer is retained;
  • lines 45–51: the calendar’s URL, with or without a booking window;
  • lines 66–98: the form for selecting a doctor and date, a component of the same file. Its state is only used before submission: it is populated with the values from URL (lines 68–69). However, [useState(value)] only uses its value during display: if the URL changes (using the “Back” button), the form would retain the previous selection. Hence the key assigned to it by the page, further down;
  • lines 114–120: the parameters for URL. The day: the day from URL if it is valid, otherwise today;
  • lines 124–129: the lists, with [TanStack Query] (example 24). The key [['doctors']] refers to the data in the cache: another page requesting the same key receives it without a new request. [enabled]: patients, only for those authorized to view them (ADMIN and doctors, for the appointment window); for a patient, the request is not sent;
  • lines 133–138: the schedule. Its key contains the doctor and the day: when URL changes, the key changes, and [TanStack Query] sends the new request on its own. If there is no doctor in the URL, a logged-in doctor sees THEIR OWN schedule; others see nothing;
  • line 141: the displayed doctor: the one from URL, otherwise the one from the received calendar;
  • lines 145–149: an invalid date in the URL: a message. This is an EFFECT: we act on the message store, not on the display of this component. We never modify another state while the display is active;
  • line 153: the time slot whose reservation window is open ([?book=7]), only if it is bookable by this user: a manually created URL does not open anything;
  • lines 162–164: “View schedule” changes the URL; the page updates accordingly;
  • line 168: closing the window returns you to the calendar’s URL. [useCallback]: the SAME function from one view to the next, because the reservation window uses it within the dependencies of an effect (see [BookingDialog.tsx]): a new function for each view would trigger the effect each time;
  • Lines 172–173: The server’s data takes precedence: after each action, the calendar (and the patient’s space, which displays their appointment) is refreshed. [TanStack Query] immediately re-reads the displayed data; data that is not displayed will be re-read the next time it is used;
  • lines 175–200: the reservation:
    • line 181: the ADMIN and the doctors send the selected patient; a patient makes a reservation for themselves;
    • Line 182: The message will appear after the navigation action that closes the window;
    • Lines 184–188: No patient selected: the window remains open, displaying the message. [return] within a [try]: the [finally] block executes anyway;
    • line 194: other errors (slot already taken, patient already has an appointment, etc.) will be displayed on the calendar, with the window closed;
  • lines 202–213: cancellation, after confirmation: [await confirm(...)] waits for the user’s response (see [stores/confirmation.ts]);
  • lines 218–272: the main content, calculated before JSX: a waiting message, the calendar, or a prompt to select a doctor. A standard [if / else] is often more readable than a cascade of [? :] messages within the JSX;
  • lines 239–245: the slot status: available, the patient’s name (in blue if it’s the logged-in patient), or “Booked”: a patient does NOT receive the names of other patients; the server does not send them;
  • lines 250–254: “Book” is a LINK, [<Link>]: it adds [&book=...] to URL, which opens the reservation window;
  • line 255: “Cancel,” if the server has indicated that the user can cancel this appointment;
  • line 277: The form key contains the doctor and the day. When these change, the key changes: [React] destroys the form and creates a new one, which starts with the new values. This is how [React] “resets” a component;
  • Lines 281–285: A patient who already has an upcoming appointment: this explains why there is no “Book” button;
  • lines 290–293: the booking window appears only if URL contains [&book=...].

10.4.10.3. src/pages/persons.config.ts

// =========================================================================
// pages/persons.config.ts - doctors or clients?
// -------------------------------------------------------------------------
// The management pages of the doctors and of the clients are IDENTICAL: same
// list, same form. As in the HTML application (abstract class
// PersonsController), they are written ONCE (Persons.tsx,
// PersonForm.tsx); only the address of the API and the
// keys of the labels change, described here. The router passes the type to each page
// (see routes.tsx).
// =========================================================================

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

export interface PersonKeys {
  title: string; // "Doctors"
  add: string; // "Add a doctor"
  edit: string; // "Edit a doctor"
  empty: string; // "No doctor."
  confirmDelete: string;
  added: string; // "Doctor {name} has been added (login: {login})."
  edited: string;
  deleted: string;
}

export const PERSON_KEYS: Record<PersonType, PersonKeys> = {
  doctors: {
    title: 'DOCTORS.TITLE',
    add: 'DOCTORS.ADD',
    edit: 'DOCTORS.EDIT',
    empty: 'DOCTORS.EMPTY',
    confirmDelete: 'DOCTORS.DELETE_CONFIRM',
    added: 'DOCTORS.ADDED',
    edited: 'DOCTORS.UPDATED',
    deleted: 'DOCTORS.DELETED',
  },
  clients: {
    title: 'CLIENTS.TITLE',
    add: 'CLIENTS.ADD',
    edit: 'CLIENTS.EDIT',
    empty: 'CLIENTS.EMPTY',
    confirmDelete: 'CLIENTS.DELETE_CONFIRM',
    added: 'CLIENTS.ADDED',
    edited: 'CLIENTS.UPDATED',
    deleted: 'CLIENTS.DELETED',
  },
};

// {name} and {login} of the "added / updated / deleted" messages
export function messageParams(p: { firstName: string; lastName: string; login?: string }): Record<string, string> {
  return { name: `${p.firstName} ${p.lastName}`, login: p.login ?? '' };
}

The pages for managing doctors and clients are IDENTICAL: same list, same form. Like the HTML application (abstract class [PersonsController]), they are written ONCE; the only things that change are the address of the API (the type, ['doctors'] or ['clients']) and the label keys, described here.

  • lines 14–23: the translation keys specific to each type;
  • line 25: [Record<PersonType, PersonKeys>]: an object that has exactly one property per type; [doctors] and [clients]: the compiler reports a missing element;
  • lines 49–51: the parameters [{{name}}] and [{{login}}] for the “added / modified / deleted” messages.

10.4.10.4. src/pages/Persons.tsx

// =========================================================================
// pages/Persons.tsx - the list of the doctors OR of the clients (ADMIN)
// -------------------------------------------------------------------------
// A single page for both: the type ('doctors' or 'clients') arrives
// as a prop (see routes.tsx); the labels specific to each are in
// persons.config.ts. Equivalent of views/persons/list.hbs of
// the HTML application.
// =========================================================================

import { Link } from 'react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api, type PersonType } from '../api/rdvmedecins.api';
import type { Person } from '../api/types';
import { useMessages } from '../stores/messages';
import { confirm } from '../stores/confirmation';
import PageTitle from '../components/PageTitle';
import { PERSON_KEYS, messageParams } from './persons.config';

export default function Persons({ type }: { type: PersonType }) {
  const { t } = useTranslation();
  const queryClient = useQueryClient();
  const keys = PERSON_KEYS[type];
  // the list; the key contains the type: doctors and clients each have their own
  const persons = useQuery({ queryKey: [type], queryFn: () => api.list(type) });

  async function remove(p: Person) {
    if (!(await confirm(keys.confirmDelete))) {
      return;
    }
    const messages = useMessages.getState();
    try {
      await api.remove(type, p.id);
      messages.showSuccess(keys.deleted, messageParams(p));
    } catch (e) {
      // a doctor who still has appointments: 409 ERRORS.DOCTOR_HAS_APPOINTMENTS
      messages.showApiError(e);
    }
    await queryClient.invalidateQueries({ queryKey: [type] });
  }

  const list = persons.data;
  let content;
  if (list === undefined) {
    // not loaded yet (or loading failed: the message is at the bottom of the page)
    content = <p className="text-muted mb-0">{t('APP.LOADING')}</p>;
  } else if (list.length === 0) {
    content = <p className="text-muted mb-0">{t(keys.empty)}</p>;
  } else {
    content = (
      <table className="table table-hover align-middle mb-0">
        <thead>
          <tr>
            <th>{t('PERSONS.COLUMN_TITLE')}</th>
            <th>{t('PERSONS.COLUMN_FIRSTNAME')}</th>
            <th>{t('PERSONS.COLUMN_LASTNAME')}</th>
            <th>{t('PERSONS.COLUMN_LOGIN')}</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {list.map((p) => (
            <tr key={p.id}>
              <td>{p.title}</td>
              <td>{p.firstName}</td>
              <td>{p.lastName}</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}/edit`}>{t('PERSONS.EDIT')}</Link>
                  <button type="button" className="btn btn-sm btn-outline-danger" onClick={() => remove(p)}>
                    {t('PERSONS.DELETE')}
                  </button>
                </div>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    );
  }

  return (
    <>
      <PageTitle titleKey={keys.title} />
      <div className="d-flex justify-content-between align-items-center mb-3">
        <h4 className="mb-0">{t(keys.title)}</h4>
        <Link className="btn btn-primary" to={`/${type}/add`}>{t(keys.add)}</Link>
      </div>
      <div className="card p-3">{content}</div>
    </>
  );
}

The list of doctors or clients. The type is passed as a property (see [routes.tsx]).

  • Line 25: the list. Its key is the type: doctors and clients each have their own. The list of doctors is the SAME data, just like the calendar list (key [['doctors']]): both benefit from the cache;
  • Lines 27–40: Deletion, after confirmation, followed by reloading the list. A doctor who still has appointments cannot be deleted: the server returns a 409 response, and a message is displayed at the bottom of the page;
  • line 31: [getState()] provides the store’s features without subscribing the page to messages: it does not need to be reloaded when a message changes;
  • lines 44–81: the content: “Loading…,” “No doctors,” or the table;
  • line 70: “Edit” is a link to the form page.

10.4.10.5. src/pages/PersonForm.tsx

// =========================================================================
// pages/PersonForm.tsx - creation OR update of a doctor or
// of a client (ADMIN)
// -------------------------------------------------------------------------
//     /doctors/add               -> creation (no id parameter)
//     /doctors/3/edit            -> update (id parameter = "3")
// (and the same addresses under /clients). Equivalent of
// views/persons/form.hbs of the HTML application.
//
// Two components:
//   - PersonForm reads the record to update (if there is one);
//   - Form is only displayed once the record has been read: it STARTS with
//     its values (initial state), without having to copy them later.
// No rule is checked here: the server validates everything, and its errors
// are displayed under the fields (see hooks/useFormSubmit.ts).
// The VERSION read with the record goes back with the update: if another
// ADMIN has modified it in the meantime, the server answers 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 { ApiError } from '../api/api-error';
import { api, type PersonType } from '../api/rdvmedecins.api';
import type { PersonData } from '../api/types';
import { useMessages } from '../stores/messages';
import { useFormSubmit } from '../hooks/useFormSubmit';
import PersonFields from '../components/PersonFields';
import InputField from '../components/InputField';
import PageTitle from '../components/PageTitle';
import ErrorPage from './ErrorPage';
import { PERSON_KEYS, messageParams } from './persons.config';

const EMPTY: PersonData = { title: 'Mr', firstName: '', lastName: '', login: '', password: '' };

// -------------------------------------------------------------------------
// the form itself
// -------------------------------------------------------------------------
interface FormProps {
  type: PersonType;
  id: number | null; // null: creation
  initial: PersonData;
}

function Form({ type, id, initial }: FormProps) {
  const { t } = useTranslation();
  const navigate = useNavigate();
  const queryClient = useQueryClient();
  const submission = useFormSubmit();
  const [form, setForm] = useState(initial);
  const keys = PERSON_KEYS[type];
  const creating = id === null;

  function edit(field: keyof PersonData, value: string) {
    setForm((f) => ({ ...f, [field]: value }));
    submission.clearError(field);
  }

  async function save(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const messages = useMessages.getState();
    const succeeded = await submission.submit(async () => {
      const p = id === null ? await api.add(type, form) : await api.edit(type, id, form);
      messages.flash('success', creating ? keys.added : keys.edited, messageParams(p));
    });
    const key = useMessages.getState().error?.key;
    if (succeeded) {
      await queryClient.invalidateQueries({ queryKey: [type] }); // the list has changed
      navigate(`/${type}`);
    } else if (key === 'ERRORS.DOCTOR_NOT_FOUND' || key === 'ERRORS.PATIENT_NOT_FOUND') {
      // deleted in the meantime by another ADMIN: back to the list
      messages.flash('error', key);
      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(creating ? keys.add : keys.edit)}</h5>
          {/* noValidate: it is the SERVER that validates (it is the reference) */}
          <form noValidate onSubmit={save}>
            <PersonFields values={form} errors={submission.errors} onChange={edit} />

            <h6 className="mt-4 text-muted">{t('PERSONS.ACCOUNT_SECTION')}</h6>
            <InputField className="mb-3" fieldId="login" label="PERSONS.LOGIN_LABEL" maxLength={30}
                        value={form.login} onChange={(v) => edit('login', v)} error={submission.errors.login}
                        help={creating ? 'PERSONS.LOGIN_HELP_CREATE' : 'PERSONS.LOGIN_HELP_EDIT'} />
            {/* never pre-filled: the password never comes back from the server */}
            <InputField className="mb-4" fieldId="password" type="password" label="PERSONS.PASSWORD_LABEL" maxLength={72}
                        autoComplete="new-password" value={form.password} onChange={(v) => edit('password', v)}
                        error={submission.errors.password} params={submission.params}
                        help={creating ? 'PERSONS.PASSWORD_HELP_CREATE' : 'PERSONS.PASSWORD_HELP_EDIT'} />

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

// -------------------------------------------------------------------------
// the page: creation, or reading of the record to update
// -------------------------------------------------------------------------
export default function PersonForm({ type }: { type: PersonType }) {
  const { id: urlId } = useParams(); // undefined for a creation
  const navigate = useNavigate();
  const keys = PERSON_KEYS[type];
  const validId = urlId === undefined || /^\d+$/.test(urlId);
  const id = urlId === undefined || !validId ? null : Number(urlId);

  // update: the record is read (including its version)
  //   - gcTime: 0: the record is not kept in the cache after the visit; the
  //     next visit will read it again, always up to date (otherwise the form
  //     would start with an outdated version: 409 CONCURRENT_UPDATE);
  //   - meta.errorHandled: a read error is handled HERE, and not
  //     by the common error handler (see main.tsx)
  const details = useQuery({
    queryKey: [type, id],
    queryFn: () => api.read(type, id!),
    enabled: id !== null,
    gcTime: 0,
    meta: { errorHandled: true },
  });

  // record that does not exist (deleted in the meantime...): back to the list
  useEffect(() => {
    if (details.error instanceof ApiError) {
      useMessages.getState().flash('error', details.error.key, details.error.params);
      navigate(`/${type}`);
    }
  }, [details.error, navigate, type]);

  // /doctors/abc/edit: this address does not exist
  if (!validId) {
    return <ErrorPage code={404} />;
  }

  const p = details.data;
  return (
    <>
      <PageTitle titleKey={id === null ? keys.add : keys.edit} />
      {id === null ? (
        <Form type={type} id={null} initial={EMPTY} />
      ) : (
        p && (
          <Form type={type} id={id}
                initial={{ title: p.title, firstName: p.firstName, lastName: p.lastName, login: p.login ?? '', password: '', version: p.version }} />
        )
      )}
    </>
  );
}

A single page for creation ([/doctors/add]) and editing ([/doctors/3/edit]), consisting of two components:

  • lines 46–106: the form itself. It is displayed only after the record has been read, and STARTS with its values ([useState(initial)]): there’s no need to copy them later, in an effect, when the record arrives:
    • lines 55–58: entering a field, and its error disappears;
    • lines 60–76: the save operation. For an update, [form] contains the VERSION read along with the record: if another ADMIN has modified it in the meantime, the server responds with a 409 [ERRORS.CONCURRENT_UPDATE];
    • lines 68–70: if successful, the list has changed: it is invalidated, then the program returns to it with the message;
    • lines 71–75: the record has been deleted in the meantime: return to the list. The other errors remain displayed on the form;
    • line 84: [noValidate]: the browser does not validate the [required] fields; it is the SERVER that validates, and that is the one that counts;
    • Line 91: The password field is never pre-filled: the password is never returned from the server;
  • Lines 111–159: the page:
    • line 112: the route parameter [:id], which is absent for a new creation;
    • lines 124–130: reading the record for an edit. [gcTime: 0]: the record is not cached after the visit; the next visit will re-read it, keeping it up to date (otherwise the form would start with an outdated version). [meta: { errorHandled: true }]: A read error is handled here, rather than by the common handler (see [main.tsx]);
    • lines 133–138: record does not exist (deleted in the meantime, address entered manually...): return to the list, with a message;
    • lines 141–143: [/doctors/abc/edit]: this address does not exist;
    • lines 149–156: a new entry: the empty form; an edit: the form, once the record has been read. [login: p.login ?? '']: a person’s login is sent only to ADMIN; it is optional in the [Person] type.

10.4.10.6. src/pages/Register.tsx

// =========================================================================
// pages/Register.tsx - "Create a patient account" (/register)
// -------------------------------------------------------------------------
// One only gets here after copying the captcha of the login page
// (button "Create a patient account"): the server has then issued a
// pass, which the route's guard has checked (see guards.ts).
// Same fields as the ADMIN's form, but the login and the
// password are mandatory, with confirmation. Equivalent of
// views/account/registration.hbs of the HTML application.
// =========================================================================

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 { Registration } from '../api/types';
import { useMessages } from '../stores/messages';
import { useFormSubmit } from '../hooks/useFormSubmit';
import PersonFields from '../components/PersonFields';
import InputField from '../components/InputField';
import PageTitle from '../components/PageTitle';

// minimum length of a password chosen by a patient: same value
// as the server (which also returns {min} with the PASSWORD_TOO_SHORT error)
const PASSWORD_MIN = '6';

export default function Register() {
  const { t } = useTranslation();
  const navigate = useNavigate();
  const submission = useFormSubmit();
  const [form, setForm] = useState<Registration>({
    title: 'Mr', firstName: '', lastName: '', login: '', password: '', confirmation: '',
  });
  // the parameters of the help message AND of the password errors
  const passwordParams = { min: PASSWORD_MIN, ...submission.params };

  function edit(field: keyof Registration, value: string) {
    setForm((f) => ({ ...f, [field]: value }));
    submission.clearError(field);
  }

  async function create(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    let login = '';
    const succeeded = await submission.submit(async () => {
      login = (await api.register(form)).login;
    });
    const messages = useMessages.getState();
    if (succeeded) {
      // back to the login page, login pre-filled
      messages.flash('success', 'REGISTRATION.DONE', { login });
      navigate(`/login?login=${encodeURIComponent(login)}`);
    } else if (messages.error?.key === 'ERRORS.CAPTCHA_BEFORE_ACCOUNT') {
      // pass expired (15 minutes): back to the captcha
      messages.flash('error', 'ERRORS.CAPTCHA_BEFORE_ACCOUNT');
      navigate('/login');
    }
  }

  return (
    <div className="row justify-content-center">
      <PageTitle titleKey="REGISTRATION.TITLE" />
      <div className="col-md-8 col-lg-6">
        <div className="card p-4">
          <h5 className="card-title mb-1">{t('REGISTRATION.TITLE')}</h5>
          <p className="text-muted small mb-3">{t('REGISTRATION.INTRO')}</p>
          <form noValidate onSubmit={create}>
            <PersonFields values={form} errors={submission.errors} onChange={edit} />

            <h6 className="mt-4 text-muted">{t('PERSONS.ACCOUNT_SECTION')}</h6>
            <InputField className="mb-3" fieldId="login" label="PERSONS.LOGIN_LABEL" maxLength={30} autoComplete="username"
                        required value={form.login} onChange={(v) => edit('login', v)} error={submission.errors.login}
                        help="REGISTRATION.LOGIN_HELP" />
            <div className="row g-3 mb-3">
              <InputField className="col-sm-6" fieldId="password" type="password" label="PERSONS.PASSWORD_LABEL" maxLength={72}
                          autoComplete="new-password" required value={form.password} onChange={(v) => edit('password', v)}
                          error={submission.errors.password} params={passwordParams} help="REGISTRATION.PASSWORD_HELP" />
              <InputField className="col-sm-6" fieldId="confirmation" type="password" label="ACCOUNT.CONFIRMATION_LABEL"
                          maxLength={72} autoComplete="new-password" required value={form.confirmation}
                          onChange={(v) => edit('confirmation', v)} error={submission.errors.confirmation} />
            </div>
            <div className="d-flex gap-2">
              <button type="submit" className="btn btn-primary" disabled={submission.submitting}>{t('REGISTRATION.SUBMIT')}</button>
              <Link className="btn btn-secondary" to="/login">{t('REGISTRATION.BACK_TO_LOGIN')}</Link>
            </div>
          </form>
        </div>
      </div>
    </div>
  );
}

Creating a patient account. You can only proceed here after entering the CAPTCHA from the login page: the server has issued a pass, which the gatekeeper has verified.

  • line 25: the minimum password length, for the help message (“At least 6 characters.”). The server also returns this with the error [ERRORS.PASSWORD_TOO_SHORT];
  • line 35: the parameters of the help message AND of the password errors: the server’s parameters, if any, take precedence;
  • lines 42–58: account creation:
    • line 52: if successful, return to the login page with the username pre-filled;
    • lines 53–57: the token has expired (15 minutes): return to the login page for a new CAPTCHA.

10.4.10.7. src/pages/PatientArea.tsx

// =========================================================================
// pages/PatientArea.tsx - "My area" (/my-area, role USER)
// -------------------------------------------------------------------------
// The home page of the patient, right after logging in:
//   - two options: [Manage my account] and [View a schedule];
//   - their upcoming appointment, if they have one, with the means to cancel it.
// Equivalent of views/patient/area.hbs of the HTML application.
// =========================================================================

import { Link } from 'react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api } from '../api/rdvmedecins.api';
import { timeRange, longDate, label } from '../formatting';
import { useMessages } from '../stores/messages';
import { confirm } from '../stores/confirmation';
import PageTitle from '../components/PageTitle';

export default function PatientArea() {
  const { t, i18n } = useTranslation();
  const queryClient = useQueryClient();
  const area = useQuery({ queryKey: ['my-area'], queryFn: api.myArea });

  async function cancel(appointmentId: number) {
    if (!(await confirm('SCHEDULE.CANCEL_CONFIRM'))) {
      return;
    }
    const messages = useMessages.getState();
    try {
      await api.cancelAppointment(appointmentId);
      messages.showSuccess('SCHEDULE.CANCELLED');
    } catch (e) {
      messages.showApiError(e);
    }
    // the area AND the schedules have changed
    await queryClient.invalidateQueries({ queryKey: ['my-area'] });
    await queryClient.invalidateQueries({ queryKey: ['schedule'] });
  }

  const a = area.data;
  if (!a) {
    return (
      <>
        <PageTitle titleKey="AREA.TITLE" />
        <p className="text-muted">{t('APP.LOADING')}</p>
      </>
    );
  }
  return (
    <>
      <PageTitle titleKey="AREA.TITLE" />
      <h4 className="mb-3">{t('AREA.HELLO', { name: label(a.patient) })}</h4>
      <div className="row g-3 mb-4">
        <div className="col-sm-6">
          <Link className="card p-4 h-100 text-decoration-none option-card" to="/my-account">
            <h5 className="mb-1">{t('AREA.MANAGE_ACCOUNT')}</h5>
            <span className="text-muted">{t('AREA.MANAGE_ACCOUNT_HELP')}</span>
          </Link>
        </div>
        <div className="col-sm-6">
          <Link className="card p-4 h-100 text-decoration-none option-card" to="/schedule">
            <h5 className="mb-1">{t('AREA.VIEW_SCHEDULE')}</h5>
            <span className="text-muted">{t('AREA.VIEW_SCHEDULE_HELP')}</span>
          </Link>
        </div>
      </div>

      <div className="card p-3">
        <h5>{t('AREA.MY_APPOINTMENT')}</h5>
        {/* normally only one (rule "one upcoming appointment at a time"),
            but the page can display several */}
        {a.appointments.map((appointment, i) => (
          <div key={appointment.id} className={`d-flex flex-wrap justify-content-between align-items-center gap-2 py-2 ${i < a.appointments.length - 1 ? 'border-bottom' : ''}`}>
            <div>
              <div className="fw-semibold">{longDate(appointment.day, i18n.language)}, {timeRange(appointment.slot)}</div>
              <div className="text-muted">
                {label(appointment.doctor)} -{' '}
                {/* the doctor's schedule on that day: the patient sees their name in it */}
                <Link to={`/schedule?${new URLSearchParams({ doctorId: String(appointment.doctor.id), day: appointment.day })}`}>
                  {t('AREA.SEE_IN_SCHEDULE')}
                </Link>
              </div>
            </div>
            <button type="button" className="btn btn-outline-danger" onClick={() => cancel(appointment.id)}>{t('AREA.CANCEL_APPOINTMENT')}</button>
          </div>
        ))}
        {a.appointments.length === 0 && <p className="text-muted mb-0">{t('AREA.NO_APPOINTMENT')}</p>}
      </div>
    </>
  );
}

The patient’s home page: two options, “Manage my account” and “View a schedule,” and their upcoming appointment, which they can cancel.

  • line 22: the patient’s area. [queryFn: api.myArea]: the function itself, without wrapping it in an arrow function; it has no parameters;
  • Lines 24–38: Cancellation, after confirmation. The area AND the schedules have changed: both are invalidated;
  • lines 41–48: as long as the area hasn’t been read, “Loading…”;
  • lines 55–58: an entire [Bootstrap] card is a link: [<Link>] accepts any content;
  • lines 72–86: the upcoming appointment. Normally only one (the “one upcoming appointment at a time” rule), but the page can display multiple ones;
  • line 79: a link to the doctor’s schedule for that day, where the patient sees their name.

10.4.10.8. src/pages/MyAccount.tsx

// =========================================================================
// pages/MyAccount.tsx - "Manage my account" (/my-account, role USER)
// -------------------------------------------------------------------------
// Two independent forms, like views/patient/account.hbs:
//   1. the update (identity, login, optional new password);
//   2. the deletion of the account - which also cancels its appointments.
// Both require the CURRENT password (checked by the server).
// Two forms, hence two states and two useFormSubmit (two sets
// of errors). As for PersonForm, the update form is only
// displayed once the account has been read, and starts with its values.
// =========================================================================

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 { MyAccountUpdate, MyAccount as Account } from '../api/types';
import { useSession } from '../stores/session';
import { useMessages } from '../stores/messages';
import { confirm } from '../stores/confirmation';
import { useFormSubmit } from '../hooks/useFormSubmit';
import PersonFields from '../components/PersonFields';
import InputField from '../components/InputField';
import PageTitle from '../components/PageTitle';

// -------------------------------------------------------------------------
// 1. the update
// -------------------------------------------------------------------------
function Update({ account }: { account: Account }) {
  const { t } = useTranslation();
  const navigate = useNavigate();
  const queryClient = useQueryClient();
  const submission = useFormSubmit();
  const [form, setForm] = useState<MyAccountUpdate>({ ...account, password: '', confirmation: '', currentPassword: '' });

  function edit(field: keyof MyAccountUpdate, value: string) {
    setForm((f) => ({ ...f, [field]: value }));
    submission.clearError(field);
  }

  async function save(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const succeeded = await submission.submit(async () => {
      // the server has issued a NEW token (name, login): the navigation
      // bar must follow
      useSession.getState().update(await api.editMyAccount(form));
      useMessages.getState().flash('success', 'MY_ACCOUNT.UPDATED');
    });
    if (succeeded) {
      await queryClient.invalidateQueries({ queryKey: ['my-area'] });
      navigate('/my-area');
    } else {
      // never a password left in the field after a rejected submission;
      // the value is changed WITHOUT going through edit(), so as not to clear
      // the error that the server has just attached to it
      setForm((f) => ({ ...f, currentPassword: '' }));
    }
  }

  return (
    <div className="card p-4 mb-4">
      <h5 className="card-title mb-3">{t('MY_ACCOUNT.TITLE')}</h5>
      <form noValidate onSubmit={save}>
        <PersonFields values={form} errors={submission.errors} onChange={edit} />

        <h6 className="mt-4 text-muted">{t('PERSONS.ACCOUNT_SECTION')}</h6>
        <InputField className="mb-3" fieldId="login" label="PERSONS.LOGIN_LABEL" maxLength={30} autoComplete="username"
                    required value={form.login} onChange={(v) => edit('login', v)} error={submission.errors.login} />
        <div className="row g-3 mb-3">
          <InputField className="col-sm-6" fieldId="password" type="password" label="MY_ACCOUNT.NEW_PASSWORD_LABEL"
                      maxLength={72} autoComplete="new-password" value={form.password}
                      onChange={(v) => edit('password', v)} error={submission.errors.password} params={submission.params}
                      help="PERSONS.PASSWORD_HELP_EDIT" />
          <InputField className="col-sm-6" fieldId="confirmation" type="password" label="ACCOUNT.CONFIRMATION_LABEL"
                      maxLength={72} autoComplete="new-password" value={form.confirmation}
                      onChange={(v) => edit('confirmation', v)} error={submission.errors.confirmation} />
        </div>
        <InputField className="mb-4" fieldId="currentPassword" type="password" label="MY_ACCOUNT.CURRENT_PASSWORD_LABEL"
                    maxLength={72} autoComplete="current-password" required value={form.currentPassword}
                    onChange={(v) => edit('currentPassword', v)} error={submission.errors.currentPassword}
                    help="MY_ACCOUNT.CURRENT_PASSWORD_HELP" />
        <div className="d-flex gap-2">
          <button type="submit" className="btn btn-primary" disabled={submission.submitting}>{t('PERSONS.SAVE')}</button>
          <Link className="btn btn-secondary" to="/my-area">{t('MY_ACCOUNT.BACK')}</Link>
        </div>
      </form>
    </div>
  );
}

// -------------------------------------------------------------------------
// 2. the deletion: a form with a single field
// -------------------------------------------------------------------------
function Deletion() {
  const { t } = useTranslation();
  const navigate = useNavigate();
  const submission = useFormSubmit();
  const [currentPassword, setCurrentPassword] = useState('');

  async function remove(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    if (!(await confirm('MY_ACCOUNT.DELETE_CONFIRM'))) {
      return;
    }
    const succeeded = await submission.submit(() => api.deleteMyAccount(currentPassword));
    if (succeeded) {
      useSession.getState().forget(); // the server has cleared the token cookie
      useMessages.getState().flash('success', 'MY_ACCOUNT.DELETED');
      navigate('/login');
    }
  }

  return (
    <div className="card p-4 border-danger">
      <h5 className="card-title text-danger mb-1">{t('MY_ACCOUNT.DELETE_TITLE')}</h5>
      <p className="text-muted small">{t('MY_ACCOUNT.DELETE_HELP')}</p>
      <form className="d-flex flex-wrap gap-2 align-items-end" noValidate onSubmit={remove}>
        <InputField className="flex-grow-1" fieldId="deletePassword" type="password" label="MY_ACCOUNT.CURRENT_PASSWORD_LABEL"
                    maxLength={72} autoComplete="current-password" required value={currentPassword}
                    onChange={(v) => {
                      setCurrentPassword(v);
                      submission.clearError('currentPassword');
                    }}
                    error={submission.errors.currentPassword} />
        <button type="submit" className="btn btn-danger" disabled={submission.submitting}>{t('MY_ACCOUNT.DELETE_SUBMIT')}</button>
      </form>
    </div>
  );
}

// -------------------------------------------------------------------------
// the page
// -------------------------------------------------------------------------
export default function MyAccount() {
  const { t } = useTranslation();
  // gcTime: 0: read again on each visit (its version must be up to date)
  const account = useQuery({ queryKey: ['my-account'], queryFn: api.myAccount, gcTime: 0 });

  return (
    <div className="row justify-content-center">
      <PageTitle titleKey="MY_ACCOUNT.TITLE" />
      <div className="col-md-8 col-lg-6">
        {account.data ? <Update account={account.data} /> : <p className="text-muted">{t('APP.LOADING')}</p>}
        <Deletion />
      </div>
    </div>
  );
}

Two independent forms, and therefore two components, each with its own state and [useFormSubmit] (two sets of errors):

  • lines 30–90: modification of the identity, login, and, optionally, the password:
    • line 35: the form starts with the account values (including its version), with the password fields empty;
    • line 47: the server has issued a NEW token, with the new name and new login, and redirects the user: it is stored in the session store, and the navigation bar updates accordingly;
    • lines 53–58: a submission was rejected: a password must never be left in a field. We change the value WITHOUT going through [edit()], so as not to clear the error that the server just attached to the field;
  • lines 95–130: Deleting the account, which also cancels its appointments:
    • line 99: a form with a single field: a single string;
    • lines 107–111: the account is deleted, and the server has cleared the token cookie: the client forgets the user, then the login page, along with the message;
  • lines 135–149: the page. [gcTime: 0]: the account is re-read on every visit; its version must be up to date. The deletion is displayed immediately; the modification, once the account has been read.

10.4.10.9. src/pages/ErrorPage.tsx

// =========================================================================
// pages/ErrorPage.tsx - the error page: 404 (unknown address) or 403
// (insufficient role, see guards.ts). Equivalent of views/error.hbs.
// =========================================================================

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

export default function ErrorPage({ code }: { code: 403 | 404 }) {
  const { t } = useTranslation();
  return (
    <div className="card p-4">
      <PageTitle titleKey="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>
  );
}

Error page: 404 (unknown address) or 403 (insufficient permissions). [code: 403 | 404]: This property can only take these two values.