Ajouter une page web
Le frontend web est une application Next.js 16 en App Router (frontend-web/src/app/) : un dossier égale un segment d'URL, et son fichier page.tsx est la page. Ce guide ajoute une page publique /transparence/tours qui affiche l'historique des tirages, à partir de la route GET /api/v1/lottery/rounds créée dans Ajouter un endpoint.
Fichiers à toucher
| Fichier | Modification |
|---|---|
frontend-web/src/types/api.types.ts |
Type de la réponse |
frontend-web/src/store/api/lotteryApi.ts |
Endpoint RTK Query et hook exporté |
frontend-web/src/components/lottery/RoundHistoryList.tsx |
Composant d'affichage, testable seul |
frontend-web/src/app/transparence/tours/page.tsx |
La page |
frontend-web/src/app/transparence/page.tsx |
Lien vers la nouvelle page |
frontend-web/__tests__/components.test.tsx |
Test du composant |
Choisir l'emplacement
L'emplacement du dossier décide de l'habillage et de la protection de la page :
| Dossier | Habillage | Accès | Exemples |
|---|---|---|---|
src/app/<page>/ |
à fournir par la page, en général PublicShell |
public | /transparence, /mentions-legales, /connexion |
src/app/(citoyen)/<page>/ |
AppShell de l'espace citoyen, posé par (citoyen)/layout.tsx |
compte connecté, tout rôle | /accueil, /billetterie, /historique |
src/app/partenaire/<page>/ |
AppShell de l'espace partenaires |
rôles PARTNER et ADMIN |
/partenaire/codes |
src/app/mairie/<page>/ |
AppShell du dashboard Mairie |
rôle ADMIN |
/mairie/securite |
Le dossier (citoyen) est un groupe de routes : ses parenthèses n'apparaissent pas dans l'URL. La protection est faite côté navigateur par RequireRole, qui redirige vers /connexion?next=... ou affiche AccessDenied. Elle améliore l'ergonomie mais ne protège rien : la vraie barrière est le contrôle authenticate et requireRoles de chaque route de l'API.
Ajouter une entrée de menu à un espace se fait dans le tableau nav du layout.tsx correspondant. Renseigner aussi son champ icon avec un nom de iconData.ts (par exemple { href: '/historique', label: t('Historique & POAP', 'History & POAP'), icon: 'tabTickets' }) : la barre latérale et la barre d'onglets affichent l'icône à côté du libellé.
1. Typer la réponse
Dans api.types.ts, à côté de LeaderboardEntry :
export interface RoundHistoryItem {
contractRoundId: number;
winner: string | null;
winningTicket: number | null;
prizeEth: number | null;
ticketsCount: number;
chainlinkRequestId: string | null;
drawnAt: string | null;
prizeClaimedAt: string | null;
}2. Déclarer l'appel API
Les appels passent tous par RTK Query. Dans lotteryApi.ts, ajouter l'endpoint dans injectEndpoints et exporter son hook :
getRoundHistory: builder.query<ApiResponse<{ rounds: RoundHistoryItem[] }>, { page?: number; limit?: number } | void>({
query: (params) => ({ url: '/lottery/rounds', params: params || { page: 1, limit: 10 } }),
providesTags: ['Lottery'],
}),export const {
useGetLotteryStatusQuery,
useGetRoundHistoryQuery,
} = lotteryApi;| Point | Détail |
|---|---|
| URL de base | baseApi préfixe NEXT_PUBLIC_API_URL (http://localhost:2053/api par défaut, lib/config.ts) et ajoute le jeton Authorization s'il existe |
| Délai | 15 s par requête (fetchBaseQuery, baseApi.ts) |
| Jeton expiré | Une réponse 401 sur une requête authentifiée déconnecte l'utilisateur |
Étiquette Lottery |
Le hook useSocket invalide Lottery à chaque événement draw:winner : la liste se rafraîchit seule après un tirage, sans code supplémentaire |
| Écriture | Une mutation déclare invalidatesTags pour rafraîchir les requêtes concernées |
3. Écrire le composant
Un composant d'affichage reçoit ses données par propriétés : il se teste sans store ni réseau. Fichier src/components/lottery/RoundHistoryList.tsx :
'use client';
import React from 'react';
import { formatEth, shortenHash } from '@/lib/formatters';
import { useAppTheme } from '@/theme/ThemeProvider';
import { RoundHistoryItem } from '@/types/api.types';
export const RoundHistoryList: React.FC<{ rounds: RoundHistoryItem[] }> = ({ rounds }) => {
const { t } = useAppTheme();
if (rounds.length === 0) {
return <p className="text-caption text-fg-muted">{t('Aucun tirage terminé.', 'No completed draw yet.')}</p>;
}
return (
<ol className="grid gap-2.5">
{rounds.map((round) => (
<li key={round.contractRoundId} className="card flex flex-col gap-1 p-4">
<h2 className="text-body font-bold">
{t('Tour', 'Round')} #{round.contractRoundId}
</h2>
<p className="text-small">
{t('Gagnant', 'Winner')} : {round.winner ?? '—'} · {t('ticket', 'ticket')} {round.winningTicket ?? '—'}
</p>
<p className="text-small">
{t('Cagnotte', 'Prize')} : {formatEth(round.prizeEth ?? 0)} · {round.ticketsCount} {t('tickets', 'tickets')}
</p>
<p className="text-caption text-fg-muted">
{t('Requête Chainlink VRF', 'Chainlink VRF request')} {shortenHash(round.chainlinkRequestId)}
</p>
</li>
))}
</ol>
);
};
export default RoundHistoryList;Règles du frontend :
| Règle | Outil |
|---|---|
| Tout texte visible est bilingue | t('français', 'english') de useAppTheme |
Couleurs et tailles par classes du thème (text-fg-muted, text-caption, card) |
Tailwind 4 et globals.css, voir Design system |
| Formatage des montants, adresses et empreintes | lib/formatters.ts |
| Composants existants avant du balisage neuf | PageHeader, EmptyState, Alert de Misc.tsx, Loader, Button, LinkButton |
'use client' en tête |
Tout composant qui utilise un hook React ou le store |
4. Créer la page
Fichier src/app/transparence/tours/page.tsx, sur le modèle de transparence/page.tsx et (citoyen)/historique/page.tsx :
'use client';
import React from 'react';
import PublicShell from '@/components/layout/PublicShell';
import RoundHistoryList from '@/components/lottery/RoundHistoryList';
import { Loader } from '@/components/ui/Loader';
import { Alert, PageHeader } from '@/components/ui/Misc';
import { useGetRoundHistoryQuery } from '@/store/api/lotteryApi';
import { useAppTheme } from '@/theme/ThemeProvider';
export default function RoundHistoryPage() {
const { t } = useAppTheme();
const { data, isLoading, isError } = useGetRoundHistoryQuery({ page: 1, limit: 20 });
return (
<PublicShell>
<div className="mx-auto max-w-5xl px-4 py-10 md:px-6 md:py-14">
<PageHeader
title={t('Historique des tirages', 'Draw history')}
subtitle={t(
'Chaque gagnant a été désigné par Chainlink VRF et peut être vérifié sur la blockchain.',
'Each winner was picked by Chainlink VRF and can be checked on the blockchain.'
)}
/>
{isLoading ? <Loader message={t('Chargement…', 'Loading…')} /> : null}
{isError ? <Alert tone="error">{t('Historique indisponible.', 'History unavailable.')}</Alert> : null}
{data ? <RoundHistoryList rounds={data.data.rounds} /> : null}
</div>
</PublicShell>
);
}Puis ajouter un lien depuis la page Transparence, sous le bouton existant :
<LinkButton href="/transparence/tours" variant="outline">
{t("Voir l'historique des tirages", 'See draw history')}
</LinkButton>À savoir
Les pages du projet sont des composants client ('use client') qui chargent leurs données dans le navigateur. Le titre de l'onglet vient du metadata global de app/layout.tsx. Une page qui a besoin de son propre titre pour le référencement doit être un composant serveur qui exporte metadata et rend un composant client.
5. Tester
Dans __tests__/components.test.tsx, le fichier enveloppe déjà les composants dans ThemeProvider avec withTheme :
import RoundHistoryList from '@/components/lottery/RoundHistoryList';
describe('RoundHistoryList', () => {
it('affiche le gagnant anonymisé et la cagnotte de chaque tour', () => {
withTheme(
<RoundHistoryList
rounds={[
{
contractRoundId: 3,
winner: '0x8a3f...91c2',
winningTicket: 2,
prizeEth: 0.036,
ticketsCount: 4,
chainlinkRequestId: '1',
drawnAt: '2026-11-11T14:32:10.000Z',
prizeClaimedAt: null,
},
]}
/>
);
expect(screen.getByText(/0x8a3f\.\.\.91c2/)).toBeInTheDocument();
expect(screen.getByText(/0\.04 ETH/)).toBeInTheDocument();
});
});formatEth arrondit à deux décimales : 0,036 devient « 0.04 ETH ». Lancer ensuite les contrôles de la CI :
cd frontend-web
npm run lint
npm run typecheck
npm test
npm run buildLe build échoue sur une erreur de type ou un import introuvable que le serveur de développement aurait toléré. Vérifier enfin la page sur http://localhost:3000/transparence/tours avec npm run dev, en clair et en sombre, en français et en anglais, et au clavier.
6. Accessibilité et sécurité
| Point | Vérification |
|---|---|
Un seul <h1> |
PageHeader le pose ; les titres suivants sont des <h2> |
| Navigation clavier | Tous les éléments interactifs sont des <button> ou des liens |
| États annoncés | Chargement et erreur visibles en texte, pas seulement par une couleur |
| Données affichées | Aucune adresse complète ni email d'un autre citoyen : l'API renvoie déjà des adresses anonymisées |
| Nouvelle origine appelée | Ajouter le domaine à connect-src de la politique de sécurité de contenu de next.config.mjs, sinon l'appel est bloqué en production |
Voir Accessibilité et responsive et Sécurité du navigateur.
Liste de contrôle
- Dossier placé dans le bon espace (public, citoyen, partenaire, Mairie).
- Type de réponse et endpoint RTK Query avec la bonne étiquette.
- Textes bilingues avec
t(). - États de chargement, d'erreur et liste vide.
- Test Vitest du composant.
-
lint,typecheck,testetbuildverts. - Captures d'écran jointes à la pull request.