Architecture cible MVC

Le projet Contrats & Co rangé, commenté fichier par fichier : la maison modèle de votre boutique

Voici l'architecture vers laquelle votre boutique doit tendre, celle de la tag ARCHITECTURE_MVC : les mêmes fonctionnalités que l'architecture simple, déplacées et rebranchées, sans rien ajouter. Une configuration centralisée avec les deux racines, une seule règle de réécriture qui transforme chaque adresse sémantique en vue, un seul gabarit pour tout le site, des actions par entité qui rendent un résultat au lieu de rediriger, et deux gardes : le jeton à usage unique sur chaque formulaire et l'échappement de tout ce qui s'affiche. Le diff entre les deux tags est le cours.

projet-web-contrat/ (tag ARCHITECTURE_MVC) ├── configuration.php les deux racines, la session, le jeton, echapper() ├── .htaccess toute adresse devient vue/<adresse>/index.php ├── index.php l'accueil sert le catalogue ├── template-ouverture.php le haut de chaque page, public ou admin ├── template-fermeture.php le bas de chaque page ├── modele/ Contrat, Soumission, Membre, et Jeton ├── accesseur/ la connexion PDO et les DAO (inchangés) ├── action/ │ ├── contrat/proposer.php une entité par dossier, un verbe par fichier │ ├── soumission/deposer.php │ └── membre/ inscrire, connecter, deconnecter └── vue/ ├── contrat/ /contrat/, /contrat/detail/, /contrat/proposer/ ├── membre/ /membre/inscription/, connexion/, profil/, deconnexion/ └── admin/ /admin/, /admin/contrat/, et ses actions dedans

La racine - configuration, gabarit et réécriture

Tout part d'ici : les deux racines, celle des fichiers et celle des adresses, la session ouverte avant toute action, le jeton et la fonction d'échappement, la règle unique de réécriture, et un seul gabarit qui sert le site public et le volet admin.

configuration.php

RACINE pour les inclusions, URL_RACINE pour les liens, la session, le jeton, echapper(). La seule chose à adapter pour déployer ailleurs.

<?php
// Les deux racines du projet. Tout chemin d'inclusion part de RACINE, tout lien part de URL_RACINE.
// URL_RACINE est le chemin de l'application vu du navigateur : a adapter a l'hebergement.
define('RACINE', __DIR__);
define('URL_RACINE', '/contrat/');

// La session s'ouvre ici, avant toute action et tout affichage, parce que les actions en ont besoin
if (session_status() === PHP_SESSION_NONE) {
    session_start();
}

// Le jeton a usage unique des formulaires vit dans la session
require_once RACINE . '/modele/Jeton.php';

// Tout ce qui vient d'un usager ou de la base passe par ici avant d'etre affiche
function echapper($texte)
{
    return htmlspecialchars((string) $texte, ENT_QUOTES, 'UTF-8');
}
?>

.htaccess

Ce qui est déjà sous vue/ ne se réécrit plus ; puis toute adresse qui n'est ni un fichier ni un répertoire devient vue/<adresse>/index.php.

DirectoryIndex index.php

RewriteEngine On
RewriteBase /contrat/

# Ce qui est deja sous vue/ ne se reecrit plus : une vue absente repond 404 au lieu de boucler
RewriteRule ^vue/ - [L]

# Toute adresse qui n'est ni un fichier ni un repertoire devient vue/<adresse>/index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.+?)/?$ vue/$1/index.php [L,QSA]

index.php

La page d'accueil sert le catalogue, sans le dupliquer.

<?php
// La page d'accueil est le catalogue des contrats : on sert la vue, sans la dupliquer
require __DIR__ . '/vue/contrat/index.php';
?>

template-ouverture.php

Le haut de chaque page ; un drapeau $sectionAdmin bascule l'entête et la feuille de style du volet admin.

<?php
require_once RACINE . '/modele/Membre.php';
// Une vue du volet admin met $sectionAdmin a vrai avant d'inclure le gabarit
$sectionAdmin = $sectionAdmin ?? false;
?>
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= echapper($sectionAdmin ? 'Administration - ' : '') ?>Contrats & Co</title>
    <link rel="stylesheet" href="<?= URL_RACINE ?>style.css">
    <?php if ($sectionAdmin): ?>
    <link rel="stylesheet" href="<?= URL_RACINE ?>vue/admin/style-admin.css">
    <?php endif; ?>
</head>
<body>
    <?php if ($sectionAdmin): ?>
    <div id="header" class="header-admin">
        <h1><a href="<?= URL_RACINE ?>">[ADMIN] CONTRATS & CO</a></h1>
        <div>
            <a href="<?= URL_RACINE ?>admin/" class="action-navigation-membre">TABLEAU DE BORD</a>
        </div>
    </div>
    <?php else: ?>
    <div id="header">
        <h1><a href="<?= URL_RACINE ?>">CONTRATS & CO</a></h1>
        <div id="recherche">
            <input type="text" class="saisie-recherche" placeholder="Rechercher par mot-clé, référence de contrat ou prestataire...">
        </div>
        <div id="navigation-membre">
            <?php if (Membre::estConnecte()): ?>
                <a href="<?= URL_RACINE ?>membre/profil/" class="action-navigation-membre">MON PROFIL</a>
                <form method="post" action="<?= URL_RACINE ?>membre/deconnexion/" class="formulaire-navigation-deconnexion">
<input type="hidden" name="jeton" value="<?= Jeton::creer() ?>">
                    <button type="submit" class="action-navigation-deconnexion">Déconnexion</button>
                </form>
            <?php else: ?>
                <a href="<?= URL_RACINE ?>membre/connexion/" class="action-navigation-membre">CONNEXION</a>
                <a href="<?= URL_RACINE ?>membre/inscription/" class="action-navigation-membre">INSCRIPTION</a>
            <?php endif; ?>
            <a href="<?= URL_RACINE ?>admin/" class="action-navigation-membre">ADMIN</a>
        </div>
    </div>
    
    <nav id="menu">
        <a class="lien-page" href="<?= URL_RACINE ?>?domaine=developpement-web">Développement Web</a>
        <a class="lien-page" href="<?= URL_RACINE ?>?domaine=mobile">Mobile</a>
        <a class="lien-page" href="<?= URL_RACINE ?>?domaine=cybersecurite">Cybersécurité</a>
        <a class="lien-page" href="<?= URL_RACINE ?>?domaine=logiciels-pro">Logiciels Pro</a>
    </nav>
    <?php endif; ?>

template-fermeture.php

Le bas de chaque page.

</body>
</html>

Le modèle - les objets métier et le jeton

Les trois objets métier n'ont pas bougé. S'y ajoute le jeton à usage unique : créé pour un formulaire, consommé une seule fois par l'action, il rend le rafraîchissement inoffensif et ferme la porte aux formulaires forgés depuis ailleurs.

modele/Contrat.php

L'objet Contrat : se construit depuis une ligne SQL ou un formulaire, filtre ce qu'on lui donne, calcule ses étoiles et son prix affiché.

<?php

class Contrat
{
    public const FILTRES = array( // Requiert PHP 5.6+
        'id' => FILTER_VALIDATE_INT,
        'reference' => FILTER_UNSAFE_RAW,
        'titre' => FILTER_UNSAFE_RAW,
        'type_service' => FILTER_UNSAFE_RAW,
        'icone' => FILTER_UNSAFE_RAW,
        'evaluation' => FILTER_VALIDATE_FLOAT,
        'nombre_evaluations' => FILTER_VALIDATE_INT,
        'prix' => FILTER_UNSAFE_RAW,
        'statut' => FILTER_UNSAFE_RAW
    );
        
    protected $id;
    protected $reference;
    protected $titre;
    protected $typeService;
    protected $icone;
    protected $evaluation;
    protected $nombreEvaluations;
    protected $prix;
    protected $statut;
    
    protected $etoilesCalculees;
    protected $prixDollars;
    protected $prixCents;
    
    public function __construct($tableau)
    {
        $tableau = filter_var_array($tableau, self::FILTRES);

        $this->id = $tableau['id'];
        $this->reference = $tableau['reference'];
        $this->titre = $tableau['titre'];
        $this->typeService = $tableau['type_service'];
        $this->icone = $tableau['icone'];
        $this->evaluation = $tableau['evaluation'] ?? 0;
        $this->nombreEvaluations = $tableau['nombre_evaluations'] ?? 0;
        $this->prix = $tableau['prix'];
        $this->statut = $tableau['statut'] ?? 'publie';
        
        $etoilesPleines = floor((float)$this->evaluation);
        $etoilesVides = 5 - $etoilesPleines;
        $this->etoilesCalculees = str_repeat('★', $etoilesPleines) . str_repeat('☆', $etoilesVides);
        
        $prixParts = explode('.', $this->prix);
        $this->prixDollars = $prixParts[0];
        $this->prixCents = isset($prixParts[1]) ? $prixParts[1] : '00';
    }
    
    public function __set($propriete, $valeur)
    {
        switch($propriete)
        {
            case 'id': $this->id = $valeur; break;
            case 'reference': $this->reference = $valeur; break;
            case 'titre': $this->titre = $valeur; break;
            case 'typeService': $this->typeService = $valeur; break;
            case 'icone': $this->icone = $valeur; break;
            case 'evaluation': $this->evaluation = $valeur; break;
            case 'nombreEvaluations': $this->nombreEvaluations = $valeur; break;
            case 'prix': $this->prix = $valeur; break;
            case 'statut': $this->statut = $valeur; break;
            case 'etoilesCalculees': $this->etoilesCalculees = $valeur; break;
            case 'prixDollars': $this->prixDollars = $valeur; break;
            case 'prixCents': $this->prixCents = $valeur; break;
        }
    }

    public function __get($propriete)
    {
        $self = get_object_vars($this);
        return $self[$propriete] ?? null;
    }    
}
?>

modele/Soumission.php

L'objet Soumission : une offre déposée sur un contrat.

<?php

class Soumission
{
    public const FILTRES = array(
        'id' => FILTER_VALIDATE_INT,
        'id_contrat' => FILTER_VALIDATE_INT,
        'courriel' => FILTER_SANITIZE_EMAIL,
        'montant_offre' => FILTER_VALIDATE_FLOAT,
        'message' => FILTER_UNSAFE_RAW,
        'date_soumission' => FILTER_UNSAFE_RAW
    );
        
    protected $id;
    protected $idContrat;
    protected $courriel;
    protected $montantOffre;
    protected $message;
    protected $dateSoumission;
    
    public function __construct($tableau)
    {
        $tableau = filter_var_array($tableau, self::FILTRES);

        $this->id = $tableau['id'] ?? null;
        $this->idContrat = $tableau['id_contrat'];
        $this->courriel = $tableau['courriel'];
        $this->montantOffre = $tableau['montant_offre'];
        $this->message = $tableau['message'];
        $this->dateSoumission = $tableau['date_soumission'] ?? null;
    }
    
    public function __set($propriete, $valeur)
    {
        switch($propriete)
        {
            case 'id': $this->id = $valeur; break;
            case 'idContrat': $this->idContrat = $valeur; break;
            case 'courriel': $this->courriel = $valeur; break;
            case 'montantOffre': $this->montantOffre = $valeur; break;
            case 'message': $this->message = $valeur; break;
            case 'dateSoumission': $this->dateSoumission = $valeur; break;
        }
    }

    public function __get($propriete)
    {
        $self = get_object_vars($this);
        return $self[$propriete] ?? null;
    }    
}
?>

modele/Membre.php

L'objet Membre : ses propriétés, et la session (connecter, déconnecter, estConnecte, identifiantConnecte).

<?php

class Membre
{
    public const FILTRES = array(
        'id' => FILTER_VALIDATE_INT,
        'courriel' => FILTER_SANITIZE_EMAIL,
        'mot_de_passe' => FILTER_UNSAFE_RAW,
        'nom' => FILTER_UNSAFE_RAW,
        'prenom' => FILTER_UNSAFE_RAW,
        'date_inscription' => FILTER_UNSAFE_RAW
    );

    protected $id;
    protected $courriel;
    protected $motDePasse;
    protected $nom;
    protected $prenom;
    protected $dateInscription;

    public function __construct($tableau)
    {
        $tableau = filter_var_array($tableau, self::FILTRES);

        $this->id = $tableau['id'] ?? null;
        $this->courriel = $tableau['courriel'];
        $this->motDePasse = $tableau['mot_de_passe'];
        $this->nom = $tableau['nom'];
        $this->prenom = $tableau['prenom'];
        $this->dateInscription = $tableau['date_inscription'] ?? null;
    }

    public function __set($propriete, $valeur)
    {
        switch($propriete)
        {
            case 'id': $this->id = $valeur; break;
            case 'courriel': $this->courriel = $valeur; break;
            case 'motDePasse': $this->motDePasse = $valeur; break;
            case 'nom': $this->nom = $valeur; break;
            case 'prenom': $this->prenom = $valeur; break;
            case 'dateInscription': $this->dateInscription = $valeur; break;
        }
    }

    public function __get($propriete)
    {
        $self = get_object_vars($this);
        return $self[$propriete] ?? null;
    }

    public function nomComplet()
    {
        return trim($this->prenom . ' ' . $this->nom);
    }

    // Gestion de la session : le membre connecte est identifie par son id, rien d'autre n'est garde en session

    public static function connecter(Membre $membre)
    {
        $_SESSION['idMembre'] = $membre->id;
    }

    public static function deconnecter()
    {
        unset($_SESSION['idMembre']);
        session_destroy();
    }

    public static function estConnecte()
    {
        return isset($_SESSION['idMembre']);
    }

    public static function identifiantConnecte()
    {
        return $_SESSION['idMembre'] ?? null;
    }
}
?>

modele/Jeton.php

creer() pose un jeton en session et le rend pour le champ caché ; consommer() ne dit vrai qu'une fois.

<?php
// Jeton a usage unique. Un formulaire en porte un dans un champ cache ; l'action le consomme.
// Le meme envoi rejoue (rafraichissement) ou un formulaire forge depuis ailleurs ne passe pas.
class Jeton
{
    public const NOMBRE_MAXIMAL = 20;

    // Cree un jeton, le garde en session, retourne sa valeur pour le champ cache
    public static function creer()
    {
        $valeur = bin2hex(random_bytes(16));
        $_SESSION['jetons'][$valeur] = true;
        // Une session longue n'en garde qu'une vingtaine : les plus vieux partent
        while (count($_SESSION['jetons']) > self::NOMBRE_MAXIMAL) {
            array_shift($_SESSION['jetons']);
        }
        return $valeur;
    }

    // Vrai une seule fois pour une valeur donnee, faux ensuite et faux pour une valeur inconnue
    public static function consommer($valeur)
    {
        if (is_string($valeur) && isset($_SESSION['jetons'][$valeur])) {
            unset($_SESSION['jetons'][$valeur]);
            return true;
        }
        return false;
    }
}
?>

L'accesseur - la porte vers les données

Inchangé depuis l'architecture simple : une seule connexion PDO, un DAO par table, les requêtes en constantes. C'est la preuve qu'une bonne couche d'accès survit à un déménagement.

accesseur/Accesseur.php

La connexion PDO unique, partagée par tous les DAO. Les identifiants montrés sont des exemples.

<?php
class Accesseur {
    protected static $pdo = null;
    
    protected static function obtenirConnexion() {
        if (self::$pdo === null) {
            $dsn = 'mysql:host=localhost;dbname=contrats;charset=utf8mb4';
            $usager = 'usager_exemple';
            $motdepasse = 'mot_de_passe_exemple';
            self::$pdo = new PDO($dsn, $usager, $motdepasse);
            self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        }
        return self::$pdo;
    }
}
?>

accesseur/ContratDAO.php

Le DAO du contrat : lister, filtrer par domaine, détailler, ajouter, éditer, effacer. C'est lui qui parle à la base.

<?php
require_once 'Accesseur.php';
require_once 'ContratSQL.php';
require_once __DIR__ . '/../modele/Contrat.php';

class ContratDAO extends Accesseur implements ContratSQL {
    
    // Pour l'interface d'administration
    public static function listerTousLesContrats() {
        $pdo = self::obtenirConnexion();
        $requete = $pdo->query(self::SQL_LISTER_TOUS);
        $liste = $requete->fetchAll(PDO::FETCH_ASSOC);
        
        $contrats = [];
        foreach ($liste as $ligne) {
            $contrats[] = new Contrat($ligne);
        }
        return $contrats;
    }

    // Pour l'interface publique (tous les actifs)
    public static function listerContratsActifs() {
        $pdo = self::obtenirConnexion();
        $requete = $pdo->query(self::SQL_LISTER_ACTIFS);
        $liste = $requete->fetchAll(PDO::FETCH_ASSOC);
        
        $contrats = [];
        foreach ($liste as $ligne) {
            $contrats[] = new Contrat($ligne);
        }
        return $contrats;
    }
    
    // Pour l'interface publique (filtrés par domaine)
    public static function listerContratsParDomaineActifs($domaine) {
        $pdo = self::obtenirConnexion();
        $requete = $pdo->prepare(self::SQL_LISTER_PAR_DOMAINE_ACTIFS);
        $requete->execute([$domaine]);
        $liste = $requete->fetchAll(PDO::FETCH_ASSOC);
        
        $contrats = [];
        foreach ($liste as $ligne) {
            $contrats[] = new Contrat($ligne);
        }
        return $contrats;
    }
    
    public static function detaillerContrat($id) {
        $pdo = self::obtenirConnexion();
        $requete = $pdo->prepare(self::SQL_DETAILLER_CONTRAT);
        $requete->execute([$id]);
        $ligne = $requete->fetch(PDO::FETCH_ASSOC);
        
        if ($ligne) {
            return new Contrat($ligne);
        }
        return null;
    }
    
    public static function ajouterContrat(Contrat $contrat) {
        $pdo = self::obtenirConnexion();
        $requete = $pdo->prepare(self::SQL_AJOUTER_CONTRAT);
        $requete->execute([
            $contrat->reference,
            $contrat->titre,
            $contrat->typeService,
            $contrat->icone,
            $contrat->prix,
            $contrat->statut
        ]);
    }
    
    public static function editerContrat(Contrat $contrat) {
        $pdo = self::obtenirConnexion();
        $requete = $pdo->prepare(self::SQL_EDITER_CONTRAT);
        $requete->execute([
            $contrat->reference,
            $contrat->titre,
            $contrat->typeService,
            $contrat->icone,
            $contrat->prix,
            $contrat->statut,
            $contrat->id
        ]);
    }
    
    public static function effacerContrat($id) {
        $pdo = self::obtenirConnexion();
        $requete = $pdo->prepare(self::SQL_EFFACER_CONTRAT);
        $requete->execute([$id]);
    }
}
?>

accesseur/ContratSQL.php

Les requêtes SQL du contrat, en constantes d'interface : jamais une requête dans une vue ni dans une action.

<?php
interface ContratSQL {
    public const SQL_LISTER_TOUS = "SELECT * FROM contrat";
    public const SQL_LISTER_ACTIFS = "SELECT * FROM contrat WHERE statut = 'publie'";
    public const SQL_LISTER_PAR_DOMAINE_ACTIFS = "SELECT * FROM contrat WHERE statut = 'publie' AND type_service = ?";
    public const SQL_DETAILLER_CONTRAT = "SELECT * FROM contrat WHERE id = ?";
    public const SQL_AJOUTER_CONTRAT = "INSERT INTO contrat (reference, titre, type_service, icone, prix, statut) VALUES (?, ?, ?, ?, ?, ?)";
    public const SQL_EDITER_CONTRAT = "UPDATE contrat SET reference = ?, titre = ?, type_service = ?, icone = ?, prix = ?, statut = ? WHERE id = ?";
    public const SQL_EFFACER_CONTRAT = "DELETE FROM contrat WHERE id = ?";
}
?>

accesseur/SoumissionDAO.php

Le DAO de la soumission : ajouter, lister par contrat.

<?php
require_once 'Accesseur.php';
require_once 'SoumissionSQL.php';
require_once __DIR__ . '/../modele/Soumission.php';

class SoumissionDAO extends Accesseur implements SoumissionSQL {
    
    public static function ajouterSoumission(Soumission $soumission) {
        $pdo = self::obtenirConnexion();
        $requete = $pdo->prepare(self::SQL_AJOUTER_SOUMISSION);
        $requete->execute([
            $soumission->idContrat,
            $soumission->courriel,
            $soumission->montantOffre,
            $soumission->message
        ]);
    }

    public static function listerParContrat($idContrat) {
        $pdo = self::obtenirConnexion();
        $requete = $pdo->prepare(self::SQL_LISTER_PAR_CONTRAT);
        $requete->execute([$idContrat]);
        $liste = $requete->fetchAll(PDO::FETCH_ASSOC);
        
        $soumissions = [];
        foreach ($liste as $ligne) {
            $soumissions[] = new Soumission($ligne);
        }
        return $soumissions;
    }
}
?>

accesseur/SoumissionSQL.php

Les requêtes SQL de la soumission.

<?php
interface SoumissionSQL {
    public const SQL_AJOUTER_SOUMISSION = "INSERT INTO soumission (id_contrat, courriel, montant_offre, message) VALUES (?, ?, ?, ?)";
    public const SQL_LISTER_PAR_CONTRAT = "SELECT * FROM soumission WHERE id_contrat = ? ORDER BY date_soumission DESC";
}
?>

accesseur/MembreDAO.php

Le DAO du membre : ajouter, détailler par identifiant ou par courriel, lister.

<?php
require_once 'Accesseur.php';
require_once 'MembreSQL.php';
require_once __DIR__ . '/../modele/Membre.php';

class MembreDAO extends Accesseur implements MembreSQL {

    public static function ajouterMembre(Membre $membre) {
        $pdo = self::obtenirConnexion();
        $requete = $pdo->prepare(self::SQL_AJOUTER_MEMBRE);
        $requete->execute([
            $membre->courriel,
            $membre->motDePasse,
            $membre->nom,
            $membre->prenom
        ]);
        return $pdo->lastInsertId();
    }

    public static function detaillerMembre($id) {
        $pdo = self::obtenirConnexion();
        $requete = $pdo->prepare(self::SQL_DETAILLER_MEMBRE);
        $requete->execute([$id]);
        $ligne = $requete->fetch(PDO::FETCH_ASSOC);
        if ($ligne) {
            return new Membre($ligne);
        }
        return null;
    }

    public static function detaillerParCourriel($courriel) {
        $pdo = self::obtenirConnexion();
        $requete = $pdo->prepare(self::SQL_DETAILLER_PAR_COURRIEL);
        $requete->execute([$courriel]);
        $ligne = $requete->fetch(PDO::FETCH_ASSOC);
        if ($ligne) {
            return new Membre($ligne);
        }
        return null;
    }

    public static function listerTousLesMembres() {
        $pdo = self::obtenirConnexion();
        $requete = $pdo->query(self::SQL_LISTER_TOUS_LES_MEMBRES);
        $liste = $requete->fetchAll(PDO::FETCH_ASSOC);

        $membres = [];
        foreach ($liste as $ligne) {
            $membres[] = new Membre($ligne);
        }
        return $membres;
    }
}
?>

accesseur/MembreSQL.php

Les requêtes SQL du membre.

<?php
interface MembreSQL {
    public const SQL_AJOUTER_MEMBRE = "INSERT INTO membre (courriel, mot_de_passe, nom, prenom) VALUES (?, ?, ?, ?)";
    public const SQL_DETAILLER_MEMBRE = "SELECT * FROM membre WHERE id = ?";
    public const SQL_DETAILLER_PAR_COURRIEL = "SELECT * FROM membre WHERE courriel = ?";
    public const SQL_LISTER_TOUS_LES_MEMBRES = "SELECT * FROM membre ORDER BY date_inscription DESC";
}
?>

L'action - un résultat, pas une redirection

Une entité par dossier, un verbe par fichier. Une action commence par consommer le jeton, fait son travail, remplit $resultat et rend la main. Elle ne produit pas de HTML et ne décide pas de la suite : la même action, incluse par une vue, donne une page ; incluse par un point d'entrée d'API, elle donnerait du JSON. Appelée directement, elle répond 403.

action/contrat/proposer.php

Enregistre un contrat proposé et rend le résultat.

<?php
if (!defined('RACINE')) { http_response_code(403); exit("Cette action ne se lance pas directement : une vue l'inclut."); }
if (!Jeton::consommer($_POST['jeton'] ?? '')) {
    $resultat = ['succes' => false, 'dejaTraite' => true];
    return;
}
require_once RACINE . '/accesseur/ContratDAO.php';

// Le statut 'en_attente' est force par le formulaire public via le champ cache
$nouveauContrat = new Contrat($_POST);
ContratDAO::ajouterContrat($nouveauContrat);

$resultat = ['succes' => true, 'contrat' => $nouveauContrat];
?>

action/soumission/deposer.php

Enregistre une soumission et rend le résultat.

<?php
if (!defined('RACINE')) { http_response_code(403); exit("Cette action ne se lance pas directement : une vue l'inclut."); }
if (!Jeton::consommer($_POST['jeton'] ?? '')) {
    $resultat = ['succes' => false, 'dejaTraite' => true];
    return;
}
require_once RACINE . '/accesseur/SoumissionDAO.php';

$soumission = new Soumission($_POST);
SoumissionDAO::ajouterSoumission($soumission);

$resultat = ['succes' => true, 'soumission' => $soumission];
?>

action/membre/inscrire.php

Refuse un courriel déjà pris, garde l'empreinte du mot de passe, connecte le membre, rend le résultat.

<?php
if (!defined('RACINE')) { http_response_code(403); exit("Cette action ne se lance pas directement : une vue l'inclut."); }
if (!Jeton::consommer($_POST['jeton'] ?? '')) {
    $resultat = ['succes' => false, 'dejaTraite' => true];
    return;
}
require_once RACINE . '/accesseur/MembreDAO.php';

$nouveauMembre = new Membre($_POST);

// Un courriel ne sert qu'a un seul compte
if (MembreDAO::detaillerParCourriel($nouveauMembre->courriel) !== null) {
    $resultat = ['succes' => false, 'erreur' => 'courriel'];
    return;
}

// Le mot de passe n'est jamais garde en clair : on en stocke l'empreinte
$nouveauMembre->motDePasse = password_hash($nouveauMembre->motDePasse, PASSWORD_DEFAULT);
$nouveauMembre->id = MembreDAO::ajouterMembre($nouveauMembre);

// Le nouveau membre est connecte immediatement
Membre::connecter($nouveauMembre);
$resultat = ['succes' => true, 'membre' => $nouveauMembre];
?>

action/membre/connecter.php

Vérifie l'empreinte ; même erreur que le courriel existe ou non.

<?php
if (!defined('RACINE')) { http_response_code(403); exit("Cette action ne se lance pas directement : une vue l'inclut."); }
if (!Jeton::consommer($_POST['jeton'] ?? '')) {
    $resultat = ['succes' => false, 'dejaTraite' => true];
    return;
}
require_once RACINE . '/accesseur/MembreDAO.php';

$courriel = filter_input(INPUT_POST, 'courriel', FILTER_SANITIZE_EMAIL);
$motDePasse = $_POST['mot_de_passe'] ?? '';

$membre = MembreDAO::detaillerParCourriel($courriel);

// Meme erreur que le courriel existe ou non : on ne renseigne pas un inconnu
if ($membre === null || !password_verify($motDePasse, $membre->motDePasse)) {
    $resultat = ['succes' => false, 'erreur' => 'identifiants'];
    return;
}

Membre::connecter($membre);
$resultat = ['succes' => true, 'membre' => $membre];
?>

action/membre/deconnecter.php

Ferme la session et rend le résultat.

<?php
if (!defined('RACINE')) { http_response_code(403); exit("Cette action ne se lance pas directement : une vue l'inclut."); }
if (!Jeton::consommer($_POST['jeton'] ?? '')) {
    $resultat = ['succes' => false, 'dejaTraite' => true];
    return;
}
require_once RACINE . '/modele/Membre.php';

Membre::deconnecter();
$resultat = ['succes' => true];
?>

La vue - les écrans adressables

Le dossier de la vue est son adresse. Chaque vue inclut la configuration, puis son action si la requête est un POST, puis les deux gabarits autour de son contenu, et affiche selon $resultat : le formulaire, ses erreurs, ou la confirmation, sur la même adresse. Tout ce qui s'affiche passe par echapper().

vue/contrat/index.php

Le catalogue, filtrable par domaine : /contrat/.

<?php
require_once __DIR__ . '/../../configuration.php';
require_once RACINE . '/accesseur/ContratDAO.php';

// Dictionnaire de traduction (Slug -> Valeur en BDD)
$traductionsDomaines = [
    'developpement-web' => 'Développement Web',
    'mobile' => 'Mobile',
    'cybersecurite' => 'Cybersécurité',
    'logiciels-pro' => 'Logiciels Pro'
];

$domaineFiltreSlug = null;
$domaineFiltreBDD = null;

if (isset($_GET['domaine']) && !empty($_GET['domaine'])) {
    $domaineFiltreSlug = $_GET['domaine'];
    
    // Si le slug existe dans notre dictionnaire, on filtre
    if (isset($traductionsDomaines[$domaineFiltreSlug])) {
        $domaineFiltreBDD = $traductionsDomaines[$domaineFiltreSlug];
        $listeContrats = ContratDAO::listerContratsParDomaineActifs($domaineFiltreBDD);
    } else {
        // Fallback si URL invalide
        $listeContrats = ContratDAO::listerContratsActifs();
    }
} else {
    $listeContrats = ContratDAO::listerContratsActifs();
}

include RACINE . '/template-ouverture.php'; 
?>
    
    <section id="contenu">
        <aside id="panneau-lateral">
            <h3>Filtrer par domaine</h3>
            <ul id="liste-categories">
                <li class="categorie-item"><a href="<?= URL_RACINE ?>" style="color: inherit; text-decoration: <?= echapper(!$domaineFiltreSlug ? 'underline' : 'none') ?>;">Tous les contrats</a></li>
                <li class="categorie-item"><a href="<?= URL_RACINE ?>?domaine=developpement-web" style="color: inherit; text-decoration: <?= echapper(($domaineFiltreSlug === 'developpement-web') ? 'underline' : 'none') ?>;">Développement Web</a></li>
                <li class="categorie-item"><a href="<?= URL_RACINE ?>?domaine=mobile" style="color: inherit; text-decoration: <?= echapper(($domaineFiltreSlug === 'mobile') ? 'underline' : 'none') ?>;">Applications Mobile</a></li>
                <li class="categorie-item"><a href="<?= URL_RACINE ?>?domaine=cybersecurite" style="color: inherit; text-decoration: <?= echapper(($domaineFiltreSlug === 'cybersecurite') ? 'underline' : 'none') ?>;">Cybersécurité</a></li>
                <li class="categorie-item"><a href="<?= URL_RACINE ?>?domaine=logiciels-pro" style="color: inherit; text-decoration: <?= echapper(($domaineFiltreSlug === 'logiciels-pro') ? 'underline' : 'none') ?>;">Logiciels Pro</a></li>
            </ul>
        </aside>
        
        <main id="affichage-principal">
            <div id="navigation-historique"><a href="<?= URL_RACINE ?>">Accueil</a> > Tous les contrats > <span><?= $domaineFiltreBDD ? echapper($domaineFiltreBDD) : 'Nouveautés' ?></span></div>
            <div id="liste-contrats">
                
                <?php if(empty($listeContrats)): ?>
                    <p style="padding: 20px;">Aucun contrat disponible dans ce domaine pour le moment.</p>
                <?php else: ?>
                    <?php foreach($listeContrats as $contrat): ?>
                    <article class="contrat" onclick="window.location.href='contrat.php?id=<?= echapper($contrat->id) ?>';">
                        <a href="<?= URL_RACINE ?>contrat/detail/?id=<?= echapper($contrat->id) ?>" class="contrat-lien">
                            <div class="contrat-client"><?= echapper($contrat->icone) ?></div>
                            <div class="contrat-reference"><?= echapper($contrat->reference) ?> | <?= echapper($contrat->typeService) ?></div>
                            <h4><?= echapper($contrat->titre) ?></h4>
                            <div class="contrat-evaluation"><?= echapper($contrat->etoilesCalculees) ?> <span>(<?= echapper($contrat->nombreEvaluations) ?> évaluations)</span></div>
                            <div class="contrat-prix">
                                <span class="contrat-prix-symbole">$</span><?= echapper($contrat->prixDollars) ?><span class="contrat-prix-cents"><?= echapper($contrat->prixCents) ?></span>
                            </div>
                            <button class="action-contrat-ajout">Ajouter au panier</button>
                        </a>
                    </article>
                    <?php endforeach; ?>
                <?php endif; ?>

            </div>
        </main>
    </section>

<?php include RACINE . '/template-fermeture.php'; ?>

vue/contrat/detail/index.php

Un contrat et ses soumissions, et le dépôt d'une soumission sur la même adresse : /contrat/detail/?id=.

<?php
require_once __DIR__ . '/../../../configuration.php';
require_once RACINE . '/accesseur/ContratDAO.php';
require_once RACINE . '/accesseur/SoumissionDAO.php';

// Vérification de la présence du paramètre id
if (!isset($_GET['id']) || empty($_GET['id'])) {
    header('Location: ' . URL_RACINE);
    exit;
}

$idContrat = $_GET['id'];

// Récupération via le DAO
$contrat = ContratDAO::detaillerContrat($idContrat);

if (!$contrat) {
    header('Location: ' . URL_RACINE);
    exit;
}

$resultat = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    include RACINE . '/action/soumission/deposer.php';
}

include RACINE . '/template-ouverture.php'; 
?>
    
    <section id="contenu">
        <aside id="panneau-lateral">
            <h3>Filtrer par domaine</h3>
            <ul id="liste-categories">
                <li class="categorie-item"><a href="<?= URL_RACINE ?>" style="color: inherit; text-decoration: none;">Tous les contrats</a></li>
                <li class="categorie-item"><a href="<?= URL_RACINE ?>?domaine=developpement-web" style="color: inherit; text-decoration: none;">Développement Web</a></li>
                <li class="categorie-item"><a href="<?= URL_RACINE ?>?domaine=mobile" style="color: inherit; text-decoration: none;">Applications Mobile</a></li>
                <li class="categorie-item"><a href="<?= URL_RACINE ?>?domaine=cybersecurite" style="color: inherit; text-decoration: none;">Cybersécurité</a></li>
                <li class="categorie-item"><a href="<?= URL_RACINE ?>?domaine=logiciels-pro" style="color: inherit; text-decoration: none;">Logiciels Pro</a></li>
            </ul>
        </aside>
        
        <main id="affichage-principal">
            <div id="navigation-historique"><a href="<?= URL_RACINE ?>">Accueil</a> > Tous les contrats > <?= echapper($contrat->typeService) ?> > <span><?= echapper($contrat->titre) ?></span></div>
            
            <article id="affichage-contrat-detail">
                <div class="contrat-client"><?= echapper($contrat->icone) ?></div>
                
                <div class="contrat-informations-techniques">
                    <div class="contrat-reference"><?= echapper($contrat->reference) ?> | <?= echapper($contrat->typeService) ?></div>
                    <h2 class="contrat-titre"><?= echapper($contrat->titre) ?></h2>
                    
                    <div class="contrat-evaluation">
                        <?= echapper($contrat->etoilesCalculees) ?> <span>(<?= echapper($contrat->nombreEvaluations) ?> évaluations vérifiées)</span>
                    </div>
                    
                    <div class="contrat-zone-transaction">
                        <div class="contrat-prix">
                            <span class="contrat-prix-symbole">$</span><?= echapper($contrat->prixDollars) ?><span class="contrat-prix-cents"><?= echapper($contrat->prixCents) ?></span>
                        </div>
                        <p class="contrat-disponibilite">Prestataire disponible immédiatement</p>
                        <button class="action-contrat-paiement">Procéder au paiement</button>
                        <button class="action-contrat-ajout">Ajouter au panier</button>
                    </div>
                    
                    <div class="contrat-description">
                        <h3>Détails de la prestation</h3>
                        <p>Cette section sera dynamisée plus tard. Pour l'instant, elle simule une description générique liée au contrat.</p>
                        <p>Livrables inclus :</p>
                        <ul>
                            <li>Design adaptatif (Responsive Web Design)</li>
                            <li>Optimisation SEO de base</li>
                            <li>Formation de 2h à la remise</li>
                            <li>Garantie de corrections de 30 jours</li>
                        </ul>
                    </div>

                    <div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--gris-border);">
                        <h3>Faire une offre sur ce contrat</h3>
                        
                        <?php if(($resultat !== null && ($resultat['succes'] || !empty($resultat['dejaTraite'])))): ?>
                            <div style="background-color: #d4edda; color: #155724; padding: 15px; border-radius: 4px; margin-bottom: 20px;">
                                ✅ Votre offre a été transmise avec succès !
                            </div>
                        <?php endif; ?>
                        
                        <form action="<?= URL_RACINE ?>contrat/detail/?id=<?= echapper($contrat->id) ?>" method="POST" style="background: #f9f9f9; padding: 20px; border-radius: 4px;">
<input type="hidden" name="jeton" value="<?= Jeton::creer() ?>">
                            <input type="hidden" name="id_contrat" value="<?= echapper($contrat->id) ?>">
                            
                            <div style="margin-bottom: 15px; display: flex; gap: 15px;">
                                <div style="flex: 1;">
                                    <label style="display: block; font-weight: bold; margin-bottom: 5px;">Votre Courriel</label>
                                    <input type="email" name="courriel" required style="width: 100%; padding: 10px; border: 1px solid #ccc;">
                                </div>
                                <div style="flex: 1;">
                                    <label style="display: block; font-weight: bold; margin-bottom: 5px;">Montant de votre offre ($)</label>
                                    <input type="number" name="montant_offre" step="0.01" required style="width: 100%; padding: 10px; border: 1px solid #ccc;">
                                </div>
                            </div>
                            
                            <div style="margin-bottom: 15px;">
                                <label style="display: block; font-weight: bold; margin-bottom: 5px;">Message pour le client</label>
                                <textarea name="message" rows="3" required style="width: 100%; padding: 10px; border: 1px solid #ccc;"></textarea>
                            </div>
                            
                            <button type="submit" style="background-color: var(--bleu-rona); color: white; border: none; padding: 10px 20px; font-weight: bold; cursor: pointer;">Soumettre mon offre</button>
                        </form>
                        
                        <h3 style="margin-top: 40px;">Offres actuelles (<?= echapper(count(SoumissionDAO::listerParContrat($contrat->id))) ?>)</h3>
                        <?php 
                        $offres = SoumissionDAO::listerParContrat($contrat->id);
                        if(empty($offres)): 
                        ?>
                            <p style="color: #777; font-style: italic;">Soyez le premier à faire une offre sur ce contrat !</p>
                        <?php else: ?>
                            <div style="display: flex; flex-direction: column; gap: 15px;">
                                <?php foreach($offres as $offre): ?>
                                    <div style="border: 1px solid #eee; padding: 15px; border-radius: 4px; border-left: 4px solid var(--jaune-accent);">
                                        <div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
                                            <strong>Offre de : <?= echapper(explode('@', $offre->courriel)[0]) ?>***</strong>
                                            <span style="font-size: 1.2rem; font-weight: bold; color: var(--vert-succes);"><?= echapper(number_format($offre->montantOffre, 2)) ?> $</span>
                                        </div>
                                        <p style="margin: 0; color: #555;"><?= echapper(nl2br($offre->message)) ?></p>
                                        <small style="color: #999; display: block; margin-top: 10px;"><?= echapper($offre->dateSoumission) ?></small>
                                    </div>
                                <?php endforeach; ?>
                            </div>
                        <?php endif; ?>
                    </div>
                </div>
            </article>

        </main>
    </section>

<?php include RACINE . '/template-fermeture.php'; ?>

vue/contrat/proposer/index.php

Le formulaire de proposition ; la confirmation est une branche de la vue : /contrat/proposer/.

<?php
require_once __DIR__ . '/../../../configuration.php';

$resultat = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    include RACINE . '/action/contrat/proposer.php';
}

include RACINE . '/template-ouverture.php';
?>
    
    <section id="contenu">
        <?php if ($resultat !== null && ($resultat['succes'] || !empty($resultat['dejaTraite']))): ?>
        <div id="confirmation-proposition" style="text-align: center; margin: 80px auto; max-width: 600px;">
            <h1 style="font-size: 4em; margin-bottom: 20px;">🎉</h1>
            <h2 style="color: #2c3e50; margin-bottom: 15px;">Demande envoyée avec succès !</h2>
            <p style="color: #7f8c8d; margin-bottom: 30px; font-size: 1.2em;">Votre proposition de mandat a bien été enregistrée. Elle est actuellement <strong>en attente de validation</strong> par l'un de nos administrateurs.</p>
            <a href="<?= URL_RACINE ?>" style="display: inline-block; background-color: #3498db; color: white; text-decoration: none; padding: 12px 24px; border-radius: 4px; font-weight: bold;">Retour au catalogue</a>
        </div>
        <?php else: ?>
        <div id="entete-catalogue">
            <h2>Proposer un mandat</h2>
            <p>Décrivez votre besoin. Un administrateur validera votre demande avant de la publier.</p>
        </div>
        
        <div style="max-width: 600px; margin: 40px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.05);">
            <form action="<?= URL_RACINE ?>contrat/proposer/" method="POST">
<input type="hidden" name="jeton" value="<?= Jeton::creer() ?>">
                <input type="hidden" name="statut" value="en_attente">
                <input type="hidden" name="reference" value="WEB-USER-<?= echapper(time()) ?>">
                <input type="hidden" name="icone" value="👤">
                
                <div style="margin-bottom: 20px;">
                    <label style="display: block; font-weight: bold; margin-bottom: 8px; color: #2c3e50;">Titre de votre projet</label>
                    <input type="text" name="titre" required style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;" placeholder="Ex: Refonte de mon site e-commerce">
                </div>
                
                <div style="margin-bottom: 20px;">
                    <label style="display: block; font-weight: bold; margin-bottom: 8px; color: #2c3e50;">Domaine d'expertise requis</label>
                    <select name="type_service" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;">
                        <option value="Développement Web">Développement Web</option>
                        <option value="Mobile">Mobile</option>
                        <option value="Cybersécurité">Cybersécurité</option>
                    </select>
                </div>
                
                <div style="margin-bottom: 20px;">
                    <label style="display: block; font-weight: bold; margin-bottom: 8px; color: #2c3e50;">Budget estimé ($)</label>
                    <input type="number" name="prix" step="0.01" required style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;" placeholder="Ex: 1500.00">
                </div>
                
                <div style="text-align: right;">
                    <button type="submit" style="background-color: #3498db; color: white; border: none; padding: 12px 24px; border-radius: 4px; font-weight: bold; cursor: pointer;">Soumettre ma demande</button>
                </div>
            </form>
        </div>
        <?php endif; ?>
    </section>

<?php include RACINE . '/template-fermeture.php'; ?>

vue/membre/inscription/index.php

Devenir membre : /membre/inscription/.

<?php
require_once __DIR__ . '/../../../configuration.php';

$resultat = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    include RACINE . '/action/membre/inscrire.php';
}

include RACINE . '/template-ouverture.php';
?>
    
    <section id="contenu">
        <?php if ($resultat !== null && !empty($resultat['dejaTraite'])): ?>
        <div id="confirmation-deja-traitee" style="text-align: center; margin: 80px auto; max-width: 600px;">
            <h2 style="color: #2c3e50; margin-bottom: 15px;">Cette demande a déjà été traitée</h2>
            <p style="color: #7f8c8d; margin-bottom: 30px; font-size: 1.2em;">Le formulaire a été renvoyé une seconde fois. Rien n'a été fait de plus.</p>
            <a href="<?= URL_RACINE ?>" style="display: inline-block; background-color: #3498db; color: white; text-decoration: none; padding: 12px 24px; border-radius: 4px; font-weight: bold;">Retour au catalogue</a>
        </div>
        <?php elseif ($resultat !== null && $resultat['succes']): ?>
        <div id="confirmation-inscription" style="text-align: center; margin: 80px auto; max-width: 600px;">
            <h1 style="font-size: 4em; margin-bottom: 20px;">👋</h1>
            <h2 style="color: #2c3e50; margin-bottom: 15px;">Bienvenue, <?= echapper($resultat['membre']->prenom) ?> !</h2>
            <p style="color: #7f8c8d; margin-bottom: 30px; font-size: 1.2em;">Votre compte est créé et vous êtes connecté.</p>
            <a href="<?= URL_RACINE ?>membre/profil/" style="display: inline-block; background-color: #3498db; color: white; text-decoration: none; padding: 12px 24px; border-radius: 4px; font-weight: bold;">Voir mon profil</a>
        </div>
        <?php else: ?>
        <div id="entete-catalogue">
            <h2>Devenir membre</h2>
            <p>Créez votre compte pour suivre vos soumissions et vos mandats.</p>
        </div>
        
        <div style="max-width: 600px; margin: 40px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.05);">
            <?php if ($resultat !== null && ($resultat['erreur'] ?? '') === 'courriel'): ?>
                <p id="message-erreur-inscription" style="background: #fdecea; color: #c0392b; padding: 12px; border-radius: 4px; margin-bottom: 20px;">Ce courriel est déjà associé à un compte. <a href="<?= URL_RACINE ?>membre/connexion/">Connectez-vous</a> plutôt.</p>
            <?php endif; ?>
            <form action="<?= URL_RACINE ?>membre/inscription/" method="POST">
<input type="hidden" name="jeton" value="<?= Jeton::creer() ?>">
                <div style="display: flex; gap: 20px;">
                    <div style="flex: 1; margin-bottom: 20px;">
                        <label style="display: block; font-weight: bold; margin-bottom: 8px; color: #2c3e50;">Prénom</label>
                        <input type="text" name="prenom" required value="<?= echapper($_POST['prenom'] ?? '') ?>" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;">
                    </div>
                    <div style="flex: 1; margin-bottom: 20px;">
                        <label style="display: block; font-weight: bold; margin-bottom: 8px; color: #2c3e50;">Nom</label>
                        <input type="text" name="nom" required value="<?= echapper($_POST['nom'] ?? '') ?>" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;">
                    </div>
                </div>
                
                <div style="margin-bottom: 20px;">
                    <label style="display: block; font-weight: bold; margin-bottom: 8px; color: #2c3e50;">Courriel</label>
                    <input type="email" name="courriel" required value="<?= echapper($_POST['courriel'] ?? '') ?>" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;" placeholder="vous@exemple.com">
                </div>
                
                <div style="margin-bottom: 20px;">
                    <label style="display: block; font-weight: bold; margin-bottom: 8px; color: #2c3e50;">Mot de passe</label>
                    <input type="password" name="mot_de_passe" required minlength="8" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;" placeholder="Au moins 8 caractères">
                </div>
                
                <div style="text-align: right;">
                    <button type="submit" style="background-color: #3498db; color: white; border: none; padding: 12px 24px; border-radius: 4px; font-weight: bold; cursor: pointer;">Créer mon compte</button>
                </div>
            </form>
        </div>
        <?php endif; ?>
    </section>

<?php include RACINE . '/template-fermeture.php'; ?>

vue/membre/connexion/index.php

Se connecter : /membre/connexion/.

<?php
require_once __DIR__ . '/../../../configuration.php';

$resultat = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    include RACINE . '/action/membre/connecter.php';
}

include RACINE . '/template-ouverture.php';
?>
    
    <section id="contenu">
        <?php if ($resultat !== null && !empty($resultat['dejaTraite'])): ?>
        <div id="confirmation-deja-traitee" style="text-align: center; margin: 80px auto; max-width: 600px;">
            <h2 style="color: #2c3e50; margin-bottom: 15px;">Cette demande a déjà été traitée</h2>
            <p style="color: #7f8c8d; margin-bottom: 30px; font-size: 1.2em;">Le formulaire a été renvoyé une seconde fois. Rien n'a été fait de plus.</p>
            <a href="<?= URL_RACINE ?>" style="display: inline-block; background-color: #3498db; color: white; text-decoration: none; padding: 12px 24px; border-radius: 4px; font-weight: bold;">Retour au catalogue</a>
        </div>
        <?php elseif ($resultat !== null && $resultat['succes']): ?>
        <div id="confirmation-connexion" style="text-align: center; margin: 80px auto; max-width: 600px;">
            <h1 style="font-size: 4em; margin-bottom: 20px;">🔓</h1>
            <h2 style="color: #2c3e50; margin-bottom: 15px;">Bonjour, <?= echapper($resultat['membre']->prenom) ?></h2>
            <p style="color: #7f8c8d; margin-bottom: 30px; font-size: 1.2em;">Vous êtes connecté.</p>
            <a href="<?= URL_RACINE ?>membre/profil/" style="display: inline-block; background-color: #3498db; color: white; text-decoration: none; padding: 12px 24px; border-radius: 4px; font-weight: bold;">Voir mon profil</a>
        </div>
        <?php else: ?>
        <div id="entete-catalogue">
            <h2>Connexion</h2>
            <p>Entrez le courriel et le mot de passe de votre compte.</p>
        </div>
        
        <div style="max-width: 600px; margin: 40px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.05);">
            <?php if ($resultat !== null): ?>
                <p id="message-erreur-connexion" style="background: #fdecea; color: #c0392b; padding: 12px; border-radius: 4px; margin-bottom: 20px;">Courriel ou mot de passe incorrect.</p>
            <?php endif; ?>
            <form action="<?= URL_RACINE ?>membre/connexion/" method="POST">
<input type="hidden" name="jeton" value="<?= Jeton::creer() ?>">
                <div style="margin-bottom: 20px;">
                    <label style="display: block; font-weight: bold; margin-bottom: 8px; color: #2c3e50;">Courriel</label>
                    <input type="email" name="courriel" required value="<?= echapper($_POST['courriel'] ?? '') ?>" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;">
                </div>
                
                <div style="margin-bottom: 20px;">
                    <label style="display: block; font-weight: bold; margin-bottom: 8px; color: #2c3e50;">Mot de passe</label>
                    <input type="password" name="mot_de_passe" required style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;">
                </div>
                
                <div style="display: flex; justify-content: space-between; align-items: center;">
                    <a href="<?= URL_RACINE ?>membre/inscription/" style="color: #3498db;">Pas encore de compte ? Inscrivez-vous</a>
                    <button type="submit" style="background-color: #3498db; color: white; border: none; padding: 12px 24px; border-radius: 4px; font-weight: bold; cursor: pointer;">Me connecter</button>
                </div>
            </form>
        </div>
        <?php endif; ?>
    </section>

<?php include RACINE . '/template-fermeture.php'; ?>

vue/membre/profil/index.php

Le profil, seule vue protégée : sans session, redirection vers la connexion.

<?php
require_once __DIR__ . '/../../../configuration.php';
require_once RACINE . '/accesseur/MembreDAO.php';

// Pas de session, pas de profil : on renvoie a la connexion
if (!Membre::estConnecte()) {
    header('Location: ' . URL_RACINE . 'membre/connexion/');
    exit;
}

$membre = MembreDAO::detaillerMembre(Membre::identifiantConnecte());
if ($membre === null) {
    Membre::deconnecter();
    header('Location: ' . URL_RACINE . 'membre/connexion/');
    exit;
}

include RACINE . '/template-ouverture.php';
?>
    
    <section id="contenu">
        <div id="entete-catalogue">
            <h2>Bonjour, <?= echapper($membre->prenom) ?></h2>
            <p>Voici les informations de votre compte.</p>
        </div>
        
        <div id="fiche-profil" style="max-width: 600px; margin: 40px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.05);">
            <div class="profil-ligne" style="display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #eee;">
                <span class="profil-etiquette" style="font-weight: bold; color: #2c3e50;">Nom complet</span>
                <span class="profil-valeur"><?= echapper($membre->nomComplet()) ?></span>
            </div>
            <div class="profil-ligne" style="display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #eee;">
                <span class="profil-etiquette" style="font-weight: bold; color: #2c3e50;">Courriel</span>
                <span class="profil-valeur"><?= echapper($membre->courriel) ?></span>
            </div>
            <div class="profil-ligne" style="display: flex; justify-content: space-between; padding: 12px 0;">
                <span class="profil-etiquette" style="font-weight: bold; color: #2c3e50;">Membre depuis</span>
                <span class="profil-valeur"><?= echapper($membre->dateInscription) ?></span>
            </div>
            
            <form method="post" action="<?= URL_RACINE ?>membre/deconnexion/" style="text-align: right; margin-top: 30px;">
<input type="hidden" name="jeton" value="<?= Jeton::creer() ?>">
                <button type="submit" style="background: transparent; color: #c0392b; border: 1px solid #c0392b; padding: 10px 20px; border-radius: 4px; font-weight: bold; cursor: pointer;">Me déconnecter</button>
            </form>
        </div>
    </section>

<?php include RACINE . '/template-fermeture.php'; ?>

vue/membre/deconnexion/index.php

La déconnexion a sa vue : un formulaire POST, jamais un lien.

<?php
require_once __DIR__ . '/../../../configuration.php';

$resultat = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    include RACINE . '/action/membre/deconnecter.php';
}

include RACINE . '/template-ouverture.php';
?>
    
    <section id="contenu">
        <div id="confirmation-deconnexion" style="text-align: center; margin: 80px auto; max-width: 600px;">
            <?php if ($resultat !== null && ($resultat['succes'] || !empty($resultat['dejaTraite']))): ?>
            <h1 style="font-size: 4em; margin-bottom: 20px;">👋</h1>
            <h2 style="color: #2c3e50; margin-bottom: 15px;">À bientôt</h2>
            <p style="color: #7f8c8d; margin-bottom: 30px; font-size: 1.2em;">Vous êtes déconnecté.</p>
            <a href="<?= URL_RACINE ?>" style="display: inline-block; background-color: #3498db; color: white; text-decoration: none; padding: 12px 24px; border-radius: 4px; font-weight: bold;">Retour au catalogue</a>
            <?php else: ?>
            <h2 style="color: #2c3e50; margin-bottom: 15px;">Se déconnecter ?</h2>
            <p style="color: #7f8c8d; margin-bottom: 30px; font-size: 1.2em;">Une déconnexion change l'état de votre session : elle passe par un formulaire, jamais par un simple lien.</p>
            <form method="post" action="<?= URL_RACINE ?>membre/deconnexion/">
<input type="hidden" name="jeton" value="<?= Jeton::creer() ?>">
                <button type="submit" style="background: transparent; color: #c0392b; border: 1px solid #c0392b; padding: 10px 20px; border-radius: 4px; font-weight: bold; cursor: pointer;">Me déconnecter</button>
            </form>
            <?php endif; ?>
        </div>
    </section>

<?php include RACINE . '/template-fermeture.php'; ?>

Le volet admin - une seule porte à fermer

Le volet administratif vit sous vue/admin/, ses actions dedans, pour que le .htaccess d'authentification de l'énoncé n'ait qu'une porte à fermer. Ajout, édition et effacement rendent un résultat comme les autres actions ; l'effacement reste un POST avec jeton.

vue/admin/index.php

Le tableau de bord : reçoit les effacements en POST, inclut les listes.

<?php
require_once __DIR__ . '/../../configuration.php';

// Le tableau de bord recoit les effacements : un formulaire POST avec l'identifiant du contrat
$resultat = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['operation'] ?? '') === 'effacer') {
    include RACINE . '/vue/admin/action/contrat/effacer.php';
}

$sectionAdmin = true;
include RACINE . '/template-ouverture.php';
?>
    
    <section id="contenu" class="contenu-admin">
        <h1>Tableau de bord administrateur</h1>
        <p class="introduction-admin">Bienvenue dans l'espace de gestion. Vous pouvez administrer les différentes entités du système ci-dessous.</p>
        
        <?php if ($resultat !== null && !empty($resultat['dejaTraite'])): ?>
        <p id="message-resultat-admin" class="message-resultat-admin">Cette demande a déjà été traitée : le formulaire a été renvoyé une seconde fois.</p>
        <?php endif; ?>
        <?php if ($resultat !== null && $resultat['succes']): ?>
        <p id="message-resultat-admin" class="message-resultat-admin">Le contrat a été effacé.</p>
        <?php endif; ?>
        
        <?php 
        // 1. Gestion des Contrats
        include RACINE . '/vue/admin/liste-contrats.php'; 
        
        // 2. Gestion des Membres
        include RACINE . '/vue/admin/liste-membres.php';

        // 3. Gestion des Transactions (À venir)
        // include RACINE . '/vue/admin/liste-transactions.php';
        ?>
    </section>

<?php include RACINE . '/template-fermeture.php'; ?>

vue/admin/liste-contrats.php

La liste des contrats ; chaque effacement est un formulaire avec jeton et identifiant.

<?php
require_once RACINE . '/accesseur/ContratDAO.php';
$listeContrats = ContratDAO::listerTousLesContrats();
?>
<div class="panneau-entite">
    <div id="liste-barre-actions">
        <h2>Gestion des contrats</h2>
        <a href="<?= URL_RACINE ?>admin/contrat/" class="action-contrat-creation">➕ Ajouter un contrat</a>
    </div>
    
    <div id="boite-administration-contrats">
        <div class="entete-administration-contrats">
            <div>Visuel</div>
            <div>Contrat & Description</div>
            <div>Domaine</div>
            <div>Tarif Unitaire</div>
                <div>Statut</div>
            <div>Actions</div>
        </div>
        
        <?php foreach ($listeContrats as $contrat): ?>
        <div class="ligne-administration-contrat">
            <div class="contrat-illustration"><?= echapper($contrat->icone) ?></div>
            <div>
                <strong><?= echapper($contrat->titre) ?></strong>
                <span class="contrat-reference"><?= echapper($contrat->reference) ?> | <?= echapper($contrat->typeService) ?></span>
            </div>
            <div><?= echapper($contrat->typeService) ?></div>
            <div class="contrat-prix"><?= echapper($contrat->prixDollars) ?>.<?= echapper($contrat->prixCents) ?>&nbsp;$</div>
            <div><?= echapper($contrat->statut === "en_attente" ? "⏳ En attente" : "✅ Publié") ?></div>
            <div class="bloc-actions-contrat">
                <a href="<?= URL_RACINE ?>admin/contrat/?id=<?= echapper($contrat->id) ?>" class="action-contrat-edition">Éditer</a>
                <form method="post" action="<?= URL_RACINE ?>admin/" class="formulaire-contrat-suppression" onsubmit="return confirm('Voulez-vous vraiment effacer ce contrat ?');">
<input type="hidden" name="jeton" value="<?= Jeton::creer() ?>">
                    <input type="hidden" name="operation" value="effacer">
                    <input type="hidden" name="id" value="<?= echapper($contrat->id) ?>">
                    <button type="submit" class="action-contrat-suppression">Effacer</button>
                </form>
            </div>
        </div>
        <?php endforeach; ?>
    </div>
</div>

vue/admin/liste-membres.php

La liste des membres inscrits.

<?php
require_once RACINE . '/accesseur/MembreDAO.php';
$listeMembres = MembreDAO::listerTousLesMembres();
?>
<div class="panneau-entite">
    <div id="liste-barre-actions-membres">
        <h2>Gestion des membres</h2>
    </div>
    
    <div id="boite-administration-membres">
        <div class="entete-administration-membres">
            <div>Membre</div>
            <div>Courriel</div>
            <div>Inscrit le</div>
        </div>
        
        <?php foreach ($listeMembres as $membre): ?>
        <div class="ligne-administration-membre">
            <div><strong><?= echapper($membre->nomComplet()) ?></strong></div>
            <div><?= echapper($membre->courriel) ?></div>
            <div><?= echapper($membre->dateInscription) ?></div>
        </div>
        <?php endforeach; ?>
        
        <?php if (count($listeMembres) === 0): ?>
        <div class="ligne-administration-membre">
            <div>Aucun membre inscrit pour le moment.</div>
        </div>
        <?php endif; ?>
    </div>
</div>

vue/admin/contrat/index.php

Le formulaire d'ajout, ou d'édition avec ?id= : un POST avec identifiant édite, sans identifiant ajoute.

<?php
require_once __DIR__ . '/../../../configuration.php';
require_once RACINE . '/accesseur/ContratDAO.php';

// Un POST avec identifiant edite, un POST sans identifiant ajoute ; la vue affiche le resultat sur la meme adresse
$resultat = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!empty($_POST['id'])) {
        include RACINE . '/vue/admin/action/contrat/editer.php';
    } else {
        include RACINE . '/vue/admin/action/contrat/ajouter.php';
    }
}

$contrat = null;
if (isset($_GET['id']) && !empty($_GET['id'])) {
    $contrat = ContratDAO::detaillerContrat($_GET['id']);
}

$sectionAdmin = true;
include RACINE . '/template-ouverture.php'; 
?>
    <section id="contenu" class="contenu-formulaire">
        <nav id="navigation-historique-admin">
            <a href="<?= URL_RACINE ?>admin/">← Retour à la liste</a>
        </nav>
        
        <div id="formulaire-conteneur-principal">
            <h2 id="titre-edition-contrat"><?= echapper($contrat ? 'Éditer un contrat' : 'Ajouter un contrat') ?></h2>
            <?php if ($resultat !== null && !empty($resultat['dejaTraite'])): ?>
        <p id="message-resultat-admin" class="message-resultat-admin">Cette demande a déjà été traitée : le formulaire a été renvoyé une seconde fois.</p>
            <?php endif; ?>
            <?php if ($resultat !== null && $resultat['succes']): ?>
            <p id="message-resultat-admin" class="message-resultat-admin">Le contrat a été <?= echapper($resultat['operation'] === 'ajout' ? 'ajouté' : 'enregistré') ?>. <a href="<?= URL_RACINE ?>admin/">Retour à la liste</a></p>
            <?php endif; ?>
            
            <form action="<?= URL_RACINE ?>admin/contrat/<?= echapper($contrat ? '?id=' . $contrat->id : '') ?>" method="POST" id="formulaire-saisie-contrat">
<input type="hidden" name="jeton" value="<?= Jeton::creer() ?>">
                <?php if ($contrat): ?>
                    <input type="hidden" name="id" value="<?= echapper($contrat->id) ?>">
                <?php endif; ?>

                <div class="contrat-ligne-saisie">
                    <div class="contrat-groupe-saisie">
                        <label class="contrat-etiquette-saisie">Titre du contrat <span>*</span></label>
                        <input type="text" name="titre" class="contrat-champ-saisie" placeholder="Ex: Audit de Cybersécurité" value="<?= echapper($contrat->titre ?? '') ?>" required>
                    </div>
                    <div class="contrat-groupe-saisie" style="flex: 0.5;">
                        <label class="contrat-etiquette-saisie">Statut <span>*</span></label>
                        <select name="statut" class="contrat-champ-saisie">
                            <option value="publie" <?= echapper(($contrat && $contrat->statut === "publie") ? "selected" : "") ?>>Publié</option>
                            <option value="en_attente" <?= echapper(($contrat && $contrat->statut === "en_attente") ? "selected" : "") ?>>En attente</option>
                        </select>
                    </div>
                </div>
                
                <div class="contrat-ligne-saisie">
                    <div class="contrat-groupe-saisie">
                        <label class="contrat-etiquette-saisie">Référence (SKU)</label>
                        <input type="text" name="reference" class="contrat-champ-saisie" placeholder="Ex: SEC-2026-03" value="<?= echapper($contrat->reference ?? '') ?>">
                    </div>
                    <div class="contrat-groupe-saisie">
                        <label class="contrat-etiquette-saisie">Valeur du contrat ($) <span>*</span></label>
                        <input type="number" name="prix" class="contrat-champ-saisie" step="0.01" placeholder="0.00" value="<?= echapper($contrat->prix ?? '') ?>" required>
                    </div>
                </div>
                
                <div class="contrat-ligne-saisie">
                    <div class="contrat-groupe-saisie">
                        <label class="contrat-etiquette-saisie">Domaine d'expertise</label>
                        <select name="type_service" class="contrat-champ-saisie">
                            <option value="Développement Web" <?= echapper(($contrat && $contrat->typeService === 'Développement Web') ? 'selected' : '') ?>>Développement Web</option>
                            <option value="Mobile" <?= echapper(($contrat && $contrat->typeService === 'Mobile') ? 'selected' : '') ?>>Mobile</option>
                            <option value="Cybersécurité" <?= echapper(($contrat && $contrat->typeService === 'Cybersécurité') ? 'selected' : '') ?>>Cybersécurité</option>
                            <option value="Logiciels Pro" <?= echapper(($contrat && $contrat->typeService === 'Logiciels Pro') ? 'selected' : '') ?>>Logiciels Pro</option>
                        </select>
                    </div>
                    <div class="contrat-groupe-saisie">
                        <label class="contrat-etiquette-saisie">Icône (Émoji)</label>
                        <select name="icone" class="contrat-champ-saisie">
                            <option value="🌐" <?= echapper(($contrat && $contrat->icone === '🌐') ? 'selected' : '') ?>>🌐 Web</option>
                            <option value="📱" <?= echapper(($contrat && $contrat->icone === '📱') ? 'selected' : '') ?>>📱 Mobile</option>
                            <option value="🛡️" <?= echapper(($contrat && $contrat->icone === '🛡️') ? 'selected' : '') ?>>🛡️ Sécurité</option>
                            <option value="🎹" <?= echapper(($contrat && $contrat->icone === '🎹') ? 'selected' : '') ?>>🎹 Audio</option>
                        </select>
                    </div>
                </div>
                
                <div id="barre-actions-edition">
                    <a href="<?= URL_RACINE ?>admin/" class="action-contrat-annulation">Annuler</a>
                    <button type="submit" class="action-contrat-sauvegarde">Enregistrer le contrat</button>
                </div>
            </form>
        </div>
    </section>

<?php include RACINE . '/template-fermeture.php'; ?>

vue/admin/action/contrat/ajouter.php

Ajoute un contrat et rend le résultat.

<?php
if (!defined('RACINE')) { http_response_code(403); exit("Cette action ne se lance pas directement : une vue l'inclut."); }
if (!Jeton::consommer($_POST['jeton'] ?? '')) {
    $resultat = ['succes' => false, 'dejaTraite' => true];
    return;
}
require_once RACINE . '/accesseur/ContratDAO.php';

$contrat = new Contrat($_POST);
ContratDAO::ajouterContrat($contrat);

$resultat = ['succes' => true, 'operation' => 'ajout', 'contrat' => $contrat];
?>

vue/admin/action/contrat/editer.php

Édite un contrat et rend le résultat.

<?php
if (!defined('RACINE')) { http_response_code(403); exit("Cette action ne se lance pas directement : une vue l'inclut."); }
if (!Jeton::consommer($_POST['jeton'] ?? '')) {
    $resultat = ['succes' => false, 'dejaTraite' => true];
    return;
}
require_once RACINE . '/accesseur/ContratDAO.php';

$contrat = new Contrat($_POST);
ContratDAO::editerContrat($contrat);

$resultat = ['succes' => true, 'operation' => 'edition', 'contrat' => $contrat];
?>

vue/admin/action/contrat/effacer.php

Efface un contrat sur identifiant entier valide et rend le résultat.

<?php
if (!defined('RACINE')) { http_response_code(403); exit("Cette action ne se lance pas directement : une vue l'inclut."); }
if (!Jeton::consommer($_POST['jeton'] ?? '')) {
    $resultat = ['succes' => false, 'dejaTraite' => true];
    return;
}
require_once RACINE . '/accesseur/ContratDAO.php';

// Un effacement exige un identifiant entier valide ; la vue n'inclut cette action que sur un POST
$idContrat = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
if ($idContrat === false || $idContrat === null) {
    $resultat = ['succes' => false, 'erreur' => 'identifiant'];
    return;
}

ContratDAO::effacerContrat($idContrat);
$resultat = ['succes' => true, 'operation' => 'effacement', 'id' => $idContrat];
?>