Un site d’annonces – l’interface utilisateur

Notre site d’annonces est presque terminé. Il ne nous reste plus qu’à coder l’interface de l’utilisateur. Il doit pouvoir gérer ses annonces (voir celles qui sont en attentes, les actives, prolonger une annonce, la détruire). Il doit aussi avoie accès à ses données personnelles et éventuellement les changer.

Pour vous simplifier la vie vous pouvez télécharger le dossier complet pour le code de cet article. C’est d’ailleurs l’état final de ce projet.

Un middleware

De la même manière qu’on a créé un middleware pour filtrer les administrateurs on va en créer un pour filtrer les utilisateurs :

php artisan make:middleware User

Et on le déclare dans le kernel :

protected $routeMiddleware = [
    ...
    'user' => \App\Http\Middleware\User::class,
];

Les routes

On va avoir besoin de routes pour les actions de l’utilisateur :

  • Affichage de l’accueil
  • Gestion des annonces
    • Annonces actives
    • Annonces obsolètes
    • Annonces en attente de modération (donc non actives)
  • Profil
    • Affichage du profil
    • Affichage formulaire de modification de l’email
    • Mise à jour de l’email
    • Affichage des données personnelles

Voilà le code pour assurer toutes ces tâches :

Route::prefix('utilisateur')->middleware('user')->group(function () {
    Route::get('/', 'UserController@index')->name('user.index');
    Route::prefix('annonces')->group(function () {
        Route::get('actives', 'UserController@actives')->name('user.actives');
        Route::get('obsoletes', 'UserController@obsoletes')->name('user.obsoletes');
        Route::get('attente', 'UserController@attente')->name('user.attente');
    });
    Route::prefix('profil')->group(function () {
        Route::get('email', 'UserController@emailEdit')->name('user.email.edit');
        Route::put('email', 'UserController@emailUpdate')->name('user.email.update');
        Route::get('donnees', 'UserController@data')->name('user.data');
    });
});

L’accès à son compte

On va prévoir un accès à l’interface utilisateur à partir de la page d’accueil dans la barre de menu.

Dans le layout app on ajoute le lien :

@admin
    ...
@else
    <li class="nav-item">
        <a class="nav-link" href="{{ route('user.index') }}">Mon compte</a>
    </li>
@endadmin

UserController

On doit créer la méthode index dans le contrôleur pour récupérer les données et les envoyer sur la vue de l’accueil :

public function index(Request $request)
{
    $ads = $this->adRepository->getByUser($request->user());

    $adAttenteCount = $this->adRepository->noActiveCount($ads);
    $adActivesCount = $this->adRepository->activeCount($ads);
    $adPerimesCount = $this->adRepository->obsoleteCount($ads);

    return view('user.index', compact('adActivesCount', 'adPerimesCount', 'adAttenteCount'));
}

Est-ce qu’on a déjà toutes les méthodes dans le repository ? On voit qu’il nous manque getByUser et activeCount.

AdRepository

On va donc créer ces deux méthodes

  • pour aller chercher les annonces de l’utilisateur (getByUser)
  • pour connaître le nombre d’annonces actives (activeCount)
public function activeCount($ads)
{
    return $ads->where('active', true)->where('limit', '>=', Carbon::now())->count();
}

public function getByUser($user)
{
    return $user->ads()->get();
}

Layout user

On crée un layout pour les pages des utilisateurs :

Avec ce code :

@include('layouts.back-head')

<body id="page-top">

    <!-- Page Wrapper -->
    <div id="wrapper">

      <!-- Sidebar -->
      <ul class="navbar-nav bg-gradient-primary sidebar sidebar-dark accordion" id="accordionSidebar">

        <!-- Sidebar - Brand -->
        <a class="sidebar-brand d-flex align-items-center justify-content-center" href="{{ url('/') }}">
          <div class="sidebar-brand-icon rotate-n-15">
            <i class="fab fa-earlybirds fa-2x"></i>
          </div>
          <div class="sidebar-brand-text mx-3">Annonces</div>
        </a>

        <!-- Divider -->
        <hr class="sidebar-divider my-0">

        <!-- Nav Item - Dashboard -->
        <li class="nav-item @if(request()->route()->getName() == 'user.index') active @endif">
          <a class="nav-link" href="{{ route('user.index') }}">
            <i class="fas fa-fw fa-tachometer-alt"></i>
            <span>Panneau</span></a>
        </li>

        <!-- Divider -->
        <hr class="sidebar-divider">

        <!-- Heading -->
        <div class="sidebar-heading">
          Annonces
        </div>

        <li class="nav-item @if(request()->route()->getName() == 'user.actives') active @endif">
            <a class="nav-link" href="{{ route('user.actives') }}">
                <i class="fas fa-fw fa-hiking"></i>
            <span>Actives</span></a>
        </li>

        <li class="nav-item @if(request()->route()->getName() == 'user.attente') active @endif">
            <a class="nav-link" href="{{ route('user.attente') }}">
                <i class="fas fa-fw fa-hourglass-start"></i>
            <span>En attente</span></a>
        </li>

        <li class="nav-item @if(request()->route()->getName() == 'user.obsoletes') active @endif">
            <a class="nav-link" href="{{ route('user.obsoletes') }}">
                <i class="fas fa-fw fa-hourglass-end"></i>
            <span>Obsolètes</span></a>
        </li>

        <!-- Divider -->
        <hr class="sidebar-divider">

        <!-- Heading -->
        <div class="sidebar-heading">
          Profil
        </div>

        <li class="nav-item @if(request()->route()->getName() == 'user.email.edit') active @endif">
            <a class="nav-link" href="{{ route('user.email.edit') }}">
                <i class="fas fa-fw fa-at"></i>
            <span>Email</span></a>
        </li>

        <li class="nav-item @if(request()->route()->getName() == 'user.data') active @endif">
            <a class="nav-link" href="{{ route('user.data') }}">
                <i class="fas fa-fw fa-database"></i>
            <span>Mes données</span></a>
        </li>

        <!-- Divider -->
        <hr class="sidebar-divider d-none d-md-block">

        <!-- Sidebar Toggler (Sidebar) -->
        <div class="text-center d-none d-md-inline">
          <button class="rounded-circle border-0" id="sidebarToggle"></button>
        </div>

      </ul>
      <!-- End of Sidebar -->

      <!-- Content Wrapper -->
      <div id="content-wrapper" class="d-flex flex-column">

        <!-- Main Content -->
        <div id="content">

        <!-- Topbar -->
        <nav class="navbar navbar-expand navbar-light bg-white topbar mb-4 static-top shadow">

            <!-- Sidebar Toggle (Topbar) -->
            <button id="sidebarToggleTop" class="btn btn-link d-md-none rounded-circle mr-3">
              <i class="fa fa-bars"></i>
            </button>

            <!-- Topbar Navbar -->
            <ul class="navbar-nav ml-auto">

              <!-- Nav Item - User Information -->
              <li class="nav-item dropdown no-arrow">
                <a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                  <span class="d-lg-inline text-gray-600 small">{{ auth()->user()->name }}</span>
                </a>
                <!-- Dropdown - User Information -->
                <div class="dropdown-menu dropdown-menu-right shadow animated--grow-in" aria-labelledby="userDropdown">
                  <div class="dropdown-divider"></div>
                  <a class="dropdown-item" href="{{ route('logout') }}"
                        onclick="event.preventDefault(); document.getElementById('logout-form').submit();">
                    <i class="fas fa-sign-out-alt fa-sm fa-fw mr-2 text-gray-400"></i>
                    Déconnexion
                  </a>
                  <form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
                    @csrf
                  </form>
                </div>
              </li>

            </ul>

          </nav>
          <!-- End of Topbar -->

          <!-- Begin Page Content -->
          <div class="container-fluid">

            @yield('content')

          </div>
          <!-- /.container-fluid -->

        </div>
        <!-- End of Main Content -->

        <!-- Footer -->
        <footer class="sticky-footer bg-white">
          <div class="container my-auto">
            <div class="copyright text-center my-auto">
              <span>Copyright &copy; Annonces 2019</span>
            </div>
          </div>
        </footer>
        <!-- End of Footer -->

      </div>
      <!-- End of Content Wrapper -->

    </div>
    <!-- End of Page Wrapper -->

    @include('layouts.back-footer')

On inclut des vues pour l’entête et le bas de page qu’on a déjà créées pour l’adminstration.

Vue user.index

Il ne nous manque plus que la vue pour l’accueil :

Avec ce code :

@extends('layouts.user')

@section('content')

    <!-- Page Heading -->
    <div class="d-sm-flex align-items-center justify-content-between mb-4">
        <h1 class="h3 mb-0 text-gray-800">Tableau de bord</h1>
    </div>

    <!-- Content Row -->
    <div class="row">

        <div class="col-xl-4 col-md-6 mb-4">
            <div class="card border-left-success shadow h-100 py-2">
                <div class="card-body">
                <div class="row no-gutters align-items-center">
                    <div class="col mr-2">
                    <div class="text-xs font-weight-bold text-success text-uppercase mb-1">Annonces actives</div>
                    <div class="h5 mb-0 font-weight-bold text-gray-800">{{ $adActivesCount }}</div>
                    </div>
                    <div class="col-auto">
                        <i class="fas fa-hiking fa-2x text-gray-300"></i>
                    </div>
                </div>
                </div>
            </div>
        </div>

        <div class="col-xl-4 col-md-6 mb-4">
            <div class="card border-left-warning shadow h-100 py-2">
                <div class="card-body">
                <div class="row no-gutters align-items-center">
                    <div class="col mr-2">
                    <div class="text-xs font-weight-bold text-warning text-uppercase mb-1">Annonces en attente</div>
                    <div class="h5 mb-0 font-weight-bold text-gray-800">{{ $adAttenteCount }}</div>
                    </div>
                    <div class="col-auto">
                        <i class="fas fa-hourglass-start fa-2x text-gray-300"></i>
                    </div>
                </div>
                </div>
            </div>
        </div>

        <div class="col-xl-4 col-md-6 mb-4">
            <div class="card border-left-danger shadow h-100 py-2">
                <div class="card-body">
                <div class="row no-gutters align-items-center">
                    <div class="col mr-2">
                    <div class="text-xs font-weight-bold text-danger text-uppercase mb-1">Annonces obsolètes</div>
                    <div class="h5 mb-0 font-weight-bold text-gray-800">{{ $adPerimesCount }}</div>
                    </div>
                    <div class="col-auto">
                        <i class="fas fa-hourglass-end fa-2x text-gray-300"></i>
                    </div>
                </div>
            </div>
        </div>

    </div>

@endsection

Et on arrive enfin dans l’accueil de l’utilisateur :

Redirection après connexion

Tant qu’on y est on va faire en sorte que quand un utilisateur se connecte il arrive directement dans son interface personnelle. Et pour faire bonne mesure on va aussi s’arranger pour que les administrateurs arrivent eux aussi directement sur la page d’administration. POur faire ça il faut jouer avec la redirection après connexion.

Dans le contrôleur LoginController ajoutez cette méthode :

public function redirectTo()
{
    return auth()->user()->admin ? route('admin.index') : route('user.index');
}

Et maintenant la redirection fonctionne comme on le voulait !

Les annonces actives

Traitons maintenant le cas des annonces actives. L’utilisateur doit pouvoir :

  • voir son annonce
  • modifier son annonce (uniquement le titre et le texte pour simplifier)
  • prolonger son annonce d’une semaine
  • supprimer son annonce

On va évidemment réutiliser du code déjà en place pour toutes ces tâches.

AdRepository

Dans le repository on crée la méthode active qui va nous donnée les annonces actives paginées pour l’utilisateur en cours :

public function active($user, $nbr)
{
    return $user->ads()->whereActive(true)->where('limit', '>=', Carbon::now())->paginate($nbr);
}

UserController

Dans le contrôleur on utilise cette méthode et on envoie les données à la vue :

public function actives(Request $request)
{
    $ads = $this->adRepository->active($request->user(), 5);

    return view('user.actives', compact('ads'));
}

Vue user.actives

On crée enfin la vue :

Avec ce code :

@extends('layouts.user')

@section('content')

    @include('partials.alerts', ['title' => 'Annonces actives'])

    @include('partials.table-add-del-view', ['edit' => true])

@endsection

@section('script')

    @include('partials.script-add-del-view')

@endsection

Et par la magie des vues partielles on récupère tout le code qu’on a déjà écrit !

Et du coup on a déjà 3 commandes qui fonctionnent : voir une annonce, prolonger une annonce, supprimer une annonce.

Par contre il nous faut ajouter la modification d’une annonce. Comme on avait créé un contrôleur de ressource (AdController) avec les routes associées on a déjà une partie de l’intendance en place.

Modifier une annonce

AdController

Dans le contrôleur on complète les deux méthodes :

use App\Http\Requests\ { AdStore, AdUpdate };

...

public function edit(Ad $ad)
{
    $this->authorize('manage', $ad);

    return view('edit', compact('ad'));
}

public function update(AdUpdate $request, Ad $ad)
{
    $this->authorize('manage', $ad);

    $this->adRepository->update($ad);

    $request->session()->flash('status', "L'annonce a bien été modifiée.");

    return back();
}

AdRepository

Dans le repository on ajoute la méthode update :

public function update($ad)
{
    $ad->update();
}

Form request AdUpdate

Comme on va avoir une validation on ajoute une Form Request :

php artisan make:request AdUpdate

Avec ce code :

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class AdUpdate extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'title' => ['required', 'string', 'max:100'],
            'texte' => ['required', 'string', 'max:1000'],
        ];
    }
}

La vue edit

On crée la vue pour le formulaire de modification :

@extends('layouts.app')

@section('content')

<div class="container">

    <div class="card bg-light">
        <h5 class="card-header">Votre annonce</h5>
        <div class="card-body">

            <div class="alert alert-warning alert-dismissible fade show" role="alert">
                Vous ne pouvez modifier que le titre et le texte de votre annonce.
                <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                    <span aria-hidden="true">&times;</span>
                </button>
            </div>

            @if(session()->has('status'))
                <div class="alert alert-success alert-dismissible fade show" role="alert">
                    {{ session('status') }}
                    <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
            @endif

            <form method="POST" action="{{ route('annonces.update', $ad->id) }}">
                @method('PUT')
                @csrf

                @include('partials.form-group', [
                    'title' => 'Titre',
                    'type' => 'text',
                    'name' => 'title',
                    'value' => $ad->title,
                    'required' => true,
                ])

                <div class="form-group">
                    <label for="texte">Texte</label>
                    <textarea class="form-control{{ $errors->has('texte') ? ' is-invalid' : '' }}" id="texte" name="texte" rows="3" required>{{ old('texte', isset($value) ? $value : $ad->texte) }}</textarea>
                    @if ($errors->has('texte'))
                        <div class="invalid-feedback">
                            {{ $errors->first('texte') }}
                        </div>
                    @endif
                </div>

                <br>

                <button type="submit" class="btn btn-primary">Valider</button>
            </form>

        </div>
    </div>
</div>

@endsection

On prévoit un petit message pour préciser qu’on ne peut changer que le titre et le texte de l’annonce.

La validation doit fonctionner :

Si tout se passe bien on affiche l’alerte :

Les annonces en attente de modération

Traitons maintenant le cas des annonces en attente . L’utilisateur doit pouvoir :

  • voir son annonce
  • supprimer son annonce

AdRepository

Dans le repository on crée la méthode attente qui va nous donnée les annonces pas encore modérées pur l’utilisateur en cours :

public function attente($user, $nbr)
{
    return $user->ads()->whereActive(false)->paginate($nbr);
}

UserController

Dans le contrôleur on utilise cette méthode et on envoie les données à la vue :

public function attente(Request $request)
{
    $ads = $this->adRepository->attente($request->user(), 5);

    return view('user.waiting', compact('ads'));
}

Vue user.waiting

On crée enfin la vue :

@extends('layouts.user')

@section('content')

    @include('partials.alerts', ['title' => 'Annonces en attente'])

    @include('partials.table-add-del-view', ['noAdd' => true])

@endsection

@section('script')

    @include('partials.script-add-del-view')

@endsection

Là aussi on bénéficie de tout le code écrit précédemment…

Et on a déjà tout le code pour le fonctionnement !

Les annonces obsolètes

Traitons maintenant le cas des annonces en attente . L’utilisateur doit pouvoir :

  • voir son annonce
  • ajouter une semaine de délai
  • supprimer son annonce

AdRepository

Dans le repository on crée la méthode obsoleteForUser qui va nous donnée les annonces qui ont dépassé la date de validité :

public function obsoleteForUser($user, $nbr)
{
    return $user->ads()->where('limit', '<', Carbon::now())->latest('limit')->paginate($nbr);
}

UserController

Dans le contrôleur on utilise cette méthode et on envoie les données à la vue :

public function obsoletes(Request $request)
{
    $ads = $this->adRepository->obsoleteForUser($request->user(), 5);

    return view('user.obsoletes', compact('ads'));
}

Vue user.obsoletes

On crée enfin la vue :

@extends('layouts.user')

@section('content')

    @include('partials.alerts', ['title' => 'Annonces obsolètes'])

    @include('partials.table-add-del-view')

@endsection

@section('script')

    @include('partials.script-add-del-view')

@endsection

Et là aussi on a déjà tout le code pour le fonctionnement !

Changement de l’email

L’utilisateur doit pouvoir modifier son email. On va lui proposer un formulaire pour le faire.

UserController

Dans le contrôleur on crée les deux méthodes ;

public function emailEdit()
{
    return view('user.email');
}

public function emailUpdate(EmailUpdate $request, Ad $ad)
{
    auth()->user()->email = $request->email;
    auth()->user()->save();

    $request->session()->flash('status', "Votre email a bien été mis à jour.");

    return back();
}

Form request EmailUpdate

Comme on va avoir une validation on ajoute une Form Request :

php artisan make:request EmailUpdate

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class EmailUpdate extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'email' => [
                'required', 'string', 'email', 'max:255',
                Rule::unique('users')->ignore(auth()->id()),
            ],
        ];
    }
}

La vue user.email

On crée la vue pour le formulaire de modification :

@extends('layouts.user')

@section('content')

    <div class="container">

        <div class="card">
            <h5 class="card-header">Vous pouvez modifier votre email ici</h5>
            <div class="card-body">

                @if(session()->has('status'))
                    <div class="alert alert-success alert-dismissible fade show" role="alert">
                        {{ session('status') }}
                        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                            <span aria-hidden="true">&times;</span>
                        </button>
                    </div>
                @endif

                <form method="POST" action="{{ route('user.email.update') }}">
                    @method('PUT')
                    @csrf

                    @include('partials.form-group', [
                        'title' => '',
                        'type' => 'text',
                        'name' => 'email',
                        'value' => auth()->user()->email,
                        'required' => true,
                    ])

                    <br>

                    <button type="submit" class="btn btn-primary">Valider</button>
                </form>

            </div>
        </div>
    </div>

@endsection

On a un traitement classique sur lequel je ne m’étends pas.

Les données personnelles

Le RGPD oblige à fournir à un utilisateur, s’il le désire, toutes les données le concernant. On va le faire ici même s’il y a peu de données.

UserController

On code la méthode data dans le contrôleur :

public function data()
{
    $user = auth()->user();

    return view('user.data', compact('user'));
}

Vue user.data

On crée la vue :

@extends('layouts.user')

@section('content')

    <div class="container">

        <div class="d-sm-flex align-items-center justify-content-between mb-4">
            <h1 class="h3 mb-0 text-gray-800">Vos données personnelles</h1>
        </div>

        <div class="card">
            <div class="card-body">
                <h5 class="card-title">A propos</h5>
                <table class="table">
                    <tbody>
                        <tr>
                            <td>Rapport généré pour</td>
                            <td>{{ $user->name }}</td>
                        </tr>
                        <tr>
                            <td>Pour le site</td>
                            <td>Annonces</td>
                        </tr>
                        <tr>
                            <td>A l'url</td>
                            <td>annonces.oo</td>
                        </tr>
                        <tr>
                            <td>Le</td>
                            <td>{{ \Carbon\Carbon::now()->format('d-m-Y') }}</td>
                        </tr>
                    </tbody>
                </table>
                <em>Vous pouvez enregistrer cette page pour conserver vos données en utilisant le menu de votre navigateur.</em>
            </div>
        </div>

        <br>

        <div class="card">
            <div class="card-body">
                <h5 class="card-title">Utilisateur</h5>
                <table class="table">
                    <tbody>
                        <tr>
                            <td>ID</td>
                            <td>{{ $user->id }}</td>
                        </tr>
                        <tr>
                            <td>Nom de connexion</td>
                            <td>{{ $user->name }}</td>
                        </tr>
                        <tr>
                            <td>Email</td>
                            <td>{{ $user->email }}</td>
                        </tr>
                        <tr>
                            <td>Date d'inscription</td>
                            <td>{{ $user->created_at->format('d-m-Y') }}</td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>

    </div>

@endsection

On est ainsi en règle ! Enfin presque…

Les pages légales

Si vous ne savez pas ce que sont les mentions légales je vous coneille un petit tour sur le site officiel.

On a prévu des liens en bas de la page d’accueil de notre site pour les mentions légales :

Routes

On ajoute les routes :

Route::view('legal', 'legal')->name('legal');
Route::view('confidentialite', 'confidentialite')->name('confidentialite');

Layout app

On complète les liens dans le layout :

<nav class="navbar navbar-expand fixed-bottom navbar-dark">
    <div class="navbar-nav ml-auto">
        <a class="nav-item nav-link " href="{{ route('legal') }}">Mentions légales</a>
        <a class="nav-item nav-link " href="{{ route('confidentialite') }}">Politique de confidentialité</a>
    </div>
</nav>

Vue legal

@extends('layouts.app')

@section('content')
<div class="container">

        <div class="card text-white bg-secondary">
            <div class="card-body">
                <h5 class="card-title">Mentions légales</h5>
                <table class="table text-white">
                    <tbody>
                        <tr>
                            <td>Raison sociale</td>
                            <td>Association des petits dénicheurs</td>
                        </tr>
                        <tr>
                            <td>Email</td>
                            <td>cestici@parla.fr</td>
                        </tr>
                        <tr>
                            <td>Téléphone</td>
                            <td>00 00 00 00 00</td>
                        </tr>
                        <tr>
                            <td>Directeur de publication</td>
                            <td>Monsieur Durand François</td>
                        </tr>
                        <tr>
                            <td>Hébergeur du site</td>
                            <td>Je stocke tout à Petaouchnoc, téléphone 00 00 00 00 00</td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>

</div>
@endsection

Politique de confidentialité

Le RGPD régit désormais la gesion des données personnelles. Là aussi si vous avez des lacunes je vous conseile d’aller sur le site de la CNIL. Ils ont d’ailleurs mis en place un atelier très bien pensé sur le sujet.

Vue confidentialite

@extends('layouts.app')

@section('content')
<div class="container">

        <div class="card text-white bg-secondary">
            <div class="card-body">
                <h5 class="card-title">Politique de confidentialité</h5>
                <div class="card-body">
                    <p>Ici le long texte que personne ne lit mais que vous devez obligatoirement prévoir si vous recueillez des données utilisateur et qui doit être conforme au RGPD.</p>
                    <p>Pour mémoire une adresse email est considrée comme une donnée personnelle au regard du RGPD.</p>
                </div>
            </div>
        </div>

</div>
@endsection

Conclusion

On arrive au bout de ce projet avec un site maintenant parfaitement fonctionnel. Je l’ai voulu suffisamment élaboré pour être réaliste sans le rendre toutefois trop chargé, un équilibre pas toujours facile à tenir. D’autre part je suis conscient de ne pas avoir tout détaillé et d’être même parfois passé assez rapidement sur certaines parties. Sinon le nombre d’articles aurait été trop important ! Je peux évidemment revenir sur certains points si certains le désirent. Bonne lecture !




Un site d’annonces – l’administration (2/2)

Dans cet article nous allons terminer la partie administration du site d’annonces. Il nous reste la gestion des annonces obsolètes et des messages à modérer.

Pour vous simplifier la vie vous pouvez télécharger le dossier complet pour le code de cet article.

Annonces obsolètes

Les routes

On doit ajouter des routes pour les annonces obsolètes :

  • affichage des annonces obsolètes
  • ajout d’une semaine à la date limite de publication pour prolonger l’annonce
  • suppression définitive de l’annonce
Route::prefix('admin')->middleware('admin')->group(function () {
    ...
    Route::prefix('annonces')->group(function () {
        Route::get('obsoletes', 'AdminController@obsoletes')->name('admin.obsoletes');
    ...
});

Route::prefix('admin/annonces')->group(function () {
    Route::middleware('ajax')->group(function () {
        Route::post('addweek/{ad}', 'AdminController@addWeek')->name('admin.addweek');
        Route::delete('destroy/{ad}', 'AdminController@destroy')->name('admin.destroy');
    });
});

On place les routes d’ajout et de suppression en dehors du groupe des administrateurs pour les rendre accessibles pour les auteurs de annonces.

AdRespository

Dans le repository on ajoute les méthode suivantes :

  • obsolete : pour renvoyer les annonces obsolètes paginées
  • addWeek : pour ajouter une semaine à l’annonce
public function obsolete($nbr)
{
    return Ad::where('limit', '<', Carbon::now())->latest('limit')->paginate($nbr);
}

public function addWeek($ad)
{
    $limit = Carbon::create($ad->limit);
    $limit->addWeek();
    $ad->limit = $limit;
    $ad->save();

    return $limit;
}

AdminController

Dans le contrôleur on utilise le repository pour envoyer les données dans la vue en précisant la pagination :

public function obsoletes()
{
    $ads = $this->adRepository->obsolete(5);

    return view('admin.obsoletes', compact('ads'));
}

Vue admin.obsoletes

On crée la vue admin.obsoletes :

Avec ce code :

@extends('layouts.admin')

@section('content')

    @include('partials.alerts', ['title' => 'Annonces obsolètes'])

    @include('partials.table-add-del-view')

@endsection

@section('script')

    @include('partials.script-add-del-view')

@endsection

On a déjà la vue partielle des alertes. On va créer les deux autres.

Vue partielle table-add-del-view

C’est la vue pour afficher le tableau :

<div class="table-responsive">
    <table class="table table-hover">
        <thead class="thead-light">
            <tr>
                <th scope="col">Titre</th>
                <th scope="col">Limite</th>
                <th scope="col"></th>
            </tr>
        </thead>
        <tbody>
            @foreach ($ads as $ad)
                <tr id="{{ $ad->id }}">
                    <td>{{ $ad->title }}</td>
                    <td class="date-id">{{ date_create($ad->limit)->format('d-m-Y') }}</td>
                    <td class="float-right">
                        <a class="btn btn-primary btn-sm" href="{{ route('annonces.show', $ad->id) }}" target="_blank" role="button" data-toggle="tooltip" title="Voir l'annonce"><i class="fas fa-eye"></i></a>
                        @isset($edit)
                            <a class="btn btn-warning btn-sm" href="{{ route('annonces.edit', $ad->id) }}" role="button" data-toggle="tooltip" title="Modifier l'annonce"><i class="fas fa-edit"></i></a>
                        @endisset
                        <i class="fas fa-spinner fa-pulse fa-lg" style="display: none"></i>
                        @empty($noAdd)
                            <a class="btn btn-success btn-sm" href="{{ route('admin.addweek', $ad->id) }}" role="button" data-id="{{ $ad->id }}" data-toggle="tooltip" title="Ajouter une semaine"><i class="fas fa-arrow-alt-circle-up"></i></a>
                        @endisset
                        <a class="btn btn-danger btn-sm" href="{{ route('admin.destroy', $ad->id) }}" role="button" data-id="{{ $ad->id }}" data-toggle="tooltip" title="Supprimer l'annonce"><i class="fas fa-trash"></i></a>
                    </td>
                </tr>
            @endforeach
        </tbody>
    </table>
</div>

<div class="d-flex">
    <div class="mx-auto">
        {{ $ads->links() }}
    </div>
</div>

Vue partielle script-add-del-view

C’est le Javascript pour gérer les actions du tableau :

<script>

    $(() => {

        const init = (e) => {
            e.preventDefault();
            let that = $(e.currentTarget);
            that.hide();
            that.closest('td').find('i.fa-spinner').show();
            return that;
        }

        const alertServer = () => {
            that.show();
            that.closest('td').find('i.fa-spinner').hide();
            $('.alert-warning').removeClass('d-none').addClass('show');
        }

        $('.alert-warning button').click(() => {
            $('.alert-warning').addClass('d-none').removeClass('show');
        });

        $('.btn-danger').click((e) => {
            that = init(e);
            $.ajax({
                method: 'delete',
                url: that.attr('href'),
            })
            .done(() => {
                document.location.reload(true);
            })
            .fail(() => {
                alertServer();
            });
        });

        $('.btn-success').click((e) => {
            that = init(e);
            $.post(that.attr('href'))
            .done((data) => {
                that.show();
                that.closest('td').find('i.fa-spinner').hide();
                that.closest('tr').find('td.date-id').text(data.limit);
                if(data.ok) that.closest('tr').addClass('table-success');
            })
            .fail(() => {
                alertServer();
            });
        });
    })

</script>

Layout admin

Dans notre layout admin il faut ajouter le lien pour accéder à la vue des annonces obsolètes (on n’avait pas renseigné le href) :

<li class="nav-item @if(request()->route()->getName() == 'admin.obsoletes') active @endif">
    <a class="nav-link" href="{{ route('admin.obsoletes') }}">
        <i class="fas fa-fw fa-hourglass-end"></i>
    <span>Obsolètes</span></a>
</li>

Le lien devient alors actif et en cliquant on arrive sur le page des annonces obsolètes :

Pour chaque annonce on dispose de 3 icônes de commande pour :

  • voir l’annonce
  • ajouter une semaine à la date de validité de l’annonce
  • supprimer l’annonce

Le codage existe déjà pour la première action et nous n’avons donc à nous intéresser aux deux dernières.

Ajouter une semaine

On va coder maintenant côté serveur pour l’ajout d’une semiane à annonce. On a déjà créé la méthode dans le repository ci-dessus.

AdminController

Il ne nous reste plus qu’à coder le contrôleur :

public function addWeek(Request $request, Ad $ad)
{
    $this->authorize('manage', $ad);

    $limit = $this->adRepository->addWeek($ad);

    return response()->json([
        'limit' => $limit->format('d-m-Y'),
        'ok' => $limit->greaterThan(Carbon::now()),
    ]);
}

On commence par vérifier que l’utilisateur est bien autoriser à faire cette action. On n’a pas encore créé la méthode manage dans cette autorisation, on va la créer, elle nous servira en fait à auroriser le rédacteur de l’annonce a lui aussi ajouter une semaine. Donc on crée cette méthode dans AdPolicy :

public function manage(User $user, Ad $ad)
{
    return $user->id == $ad->user_id;
}

Ensuite dans le contrôleur on fait appel au repository pour ajouter effectivement une semaine à l’annonce. pour finir on retourne au client la nouvelle date limite de l’annonce et aussi une valeur booléenne qui indique si l’annonce est devenue valide. Il serait délicat de coder ça côté client, autant le faire ici avec les possibilités vraiment pointues de Carbon.

Fonctionnement

Voyons maintenant le fonctionnement pour l’ajout d’une semaine à l’annonce…

Dans la vue partielle du tableau pour le lien d’ajout on a ce code :

<a class="btn btn-success btn-sm" href="{{ route('admin.addweek', $ad->id) }}" role="button" data-id="{{ $ad->id }}" data-toggle="tooltip" title="Ajouter une semaine"><i class="fas fa-arrow-alt-circle-up"></i></a>

L’url générée est de la forme admin/annonces/addweek/{annonce_id}. Comme on est en Ajax c’est le Javascript qui gère la requête :

$('.btn-success').click((e) => {
    that = init(e);
    $.post(that.attr('href'))
    .done((data) => {
        that.show();
        that.closest('td').find('i.fa-spinner').hide();
        that.closest('tr').find('td.date-id').text(data.limit);
        if(data.ok) that.closest('tr').addClass('table-success');
    })
    .fail(() => {
        alertServer();
    });
});

Comme on l’a déjà vu pour les actions précédentes on affiche une icône animée le temps de la communication avec le serveur.

On a vu plus haut le code du contrôleur qui gère la requête. On sait qu’on renvoie deux informations :

  • la nouvelle date limite
  • un booléen pour dire si l’annonce est devenue active

On a donc deux cas :

  • l’annonce a une semaine de plus mais n’est pas encore active, on change juste la date :
that.closest('tr').find('td.date-id').text(data.limit);
  • l’annonce est devenue active alors on met le fond en vert pour le signaler visuellement :
if(data.ok) that.closest('tr').addClass('table-success');

Supprimer une annonce

On va coder maintenant côté serveur pour la suppression d’une annonce.

AdminController

Il n’y a qu’à coder le contrôleur :

public function destroy(Request $request, Ad $ad)
{
    $this->authorize('manage', $ad);

    $this->adRepository->delete($ad);

    $request->session()->flash('status', "L'annonce a bien été supprimée.");

    return response()->json();
}

On vérifie qu’on a le droit de supprimer l’annonce (administrateur ou auteur comme on l’a déjà vu plus haut). On utilise le repository pour supprimer effectivement l’annonce. On envoie un message en session flash et enfin on renvoie une réponse vide.

Fonctionnement

Voyons maintenant le fonctionnement pour l’approbation d’une annonce…

Dans la vue du tableau pour le lien de suppression on a ce code :

<a class="btn btn-danger btn-sm" href="{{ route('admin.destroy', $ad->id) }}" role="button" data-id="{{ $ad->id }}" data-toggle="tooltip" title="Supprimer l'annonce"><i class="fas fa-trash"></i></a>

L’url générée est de la forme admin/annonces/destroy/{annonce_id}. Comme on est en Ajax c’est le Javascript qui gère la requête :

$('.btn-danger').click(e => {
    that = init(e);
    $.ajax({
        method: 'delete',
        url: that.attr('href'),
    })
    .done(() => {
        document.location.reload(true);
    })
    .fail(() => {
        alertServer();
    });
});

Si la réponse du serveur est positive (done) on recharge la page et l’alerte s’affiche :

Sinon on affiche un ealerte serveur comme on l’a déjà vu :

Messages à modérer

On a vu que lorsqu’un visiteur (non connecté) laisse un message à un auteur d’annonce ce message est mémorisé et doit être validé par un administrateur. On va maintenant coder cette partie.

Les routes

On doit ajouter des routes pour la modération des messages :

  • accès à la liste
  • approbation
  • refus
Route::prefix('admin')->middleware('admin')->group(function () {
    ...
    Route::prefix('messages')->group(function () {
        Route::get('/', 'AdminController@messages')->name('admin.messages');
        Route::post('approve/{message}', 'AdminController@messageApprove')->name('admin.message.approve');
        Route::post('refuse', 'AdminController@messageRefuse')->name('admin.message.refuse');
    });
    ...
});

Dans le groupe des administrateurs on crée un nouveau groupe messages avec les 3 routes.

MessageRepository

Dans le repository on crée la methode all pour récupérer tous les messages en attente :

public function all($nbr)
{
    return Message::latest()->paginate($nbr);
}

AdminController

Dans le contrôleur on crée la méthode messages et on fait appel au repository pour récupérer les messages paginés et les envoyer dans la vue :

public function messages()
{
    $messages = $this->messagerepository->all(5);

    return view('admin.messages', compact('messages'));
}

Vue admin.messages

On crée la vue admin.messages :

Avec ce code :

@extends('layouts.admin')

@section('content')

    @include('partials.message', ['url' => route('admin.message.refuse')])

    @include('partials.alerts', ['title' => 'Messages à modérer'])

    <div class="table-responsive">
        <table class="table table-hover">
            <thead class="thead-light">
                <tr>
                    <th scope="col">Email</th>
                    <th scope="col">Texte</th>
                    <th scope="col"></th>
                </tr>
            </thead>
            <tbody>
                @foreach ($messages as $message)
                    <tr id="{{ $message->id }}">
                        <td>{{ $message->email }}</td>
                        <td>{{ $message->texte }}</td>
                        <td class="float-right">
                            <a class="btn btn-success btn-sm" href="{{ route('admin.message.approve', $message->id) }}" role="button" data-toggle="tooltip" title="Approuver le message"><i class="fas fa-thumbs-up"></i></a>
                            <i class="fas fa-spinner fa-pulse fa-lg" style="display: none"></i>
                            <a class="btn btn-danger btn-sm" href="#" role="button" data-id="{{ $message->id }}" data-toggle="tooltip" title="Refuser le message"><i class="fas fa-thumbs-down"></i></a>
                        </td>
                    </tr>
                @endforeach
            </tbody>
        </table>
    </div>

    <div class="d-flex">
        <div class="mx-auto">
            {{ $messages->links() }}
        </div>
    </div>

@endsection

@section('script')

    @include('partials.script')

@endsection

On a déjà créer toutes les vues partielles utilisées (d’où l’intérêt de ces vues parteilles pour partager du code).

Layout admin

Dans notre layout admin il faut ajouter le lien pour accéder à la vue des annonces à modérer (on n’avait pas renseigné le href) :

<li class="nav-item @if(request()->route()->getName() == 'admin.messages') active @endif">
    <a class="nav-link" href="{{ route('admin.messages') }}">
        <i class="fas fa-fw fa-question"></i>
    <span>A modérer</span></a>
</li>

Le lien devient alors actif et en cliquant on arrive sur le page des messages à modérer :

Pour chaque message on dispose de 2 icônes de commande pour :

  • approuver le message
  • refuser le message

Approuver un message

MessageRepository

On va avoir besoin de récupérer l’annonce qui correspond à un message et aussi de supprimer le message :

public function getAd($message)
{
    return $message->ad()->firstOrFail();
}

public function delete($message)
{
    $message->delete();
}

Notification MessageApprove

On va prévoir aussi une notification pour l’auteur du message, pour le prévenir que son message a été approuvé :

php artisan make:notification MessageApprove

Avec ce code :

<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use App\Models\ { Ad, Message };

class MessageApprove extends Notification
{
    use Queueable;

    protected $ad;
    protected $message;


    public function __construct(Ad $ad, Message $message)
    {
        $this->ad = $ad;
        $this->message = $message;
    }


    public function via($notifiable)
    {
        return ['mail'];
    }


    public function toMail($notifiable)
    {
        return (new MailMessage)
                ->line('Nous avons approuvé ce message que vous avez déposé pour une annonce :')
                ->line('--------------------------------------')
                ->line($this->message->texte)
                ->line('--------------------------------------')
                ->action('Voir cette annonce', route('annonces.show', $this->ad->id))
                ->line("Merci d'utiliser notre site !");
    }


    public function toArray($notifiable)
    {
        return [
            //
        ];
    }
}

AdminController

Il ne nous reste plus qu’à coder le contrôleur

use App\Notifications\ { AdApprove, AdRefuse, MessageApprove };
use App\Models\ { Ad, Message };

...


public function messageApprove(Request $request, Message $message)
{
    $ad = $this->messagerepository->getAd($message);

    $ad->notify(new MessageApprove($ad, $message));

    $this->messagerepository->delete($message);

    $request->session()->flash('status', "Le message a bien été approuvé et le rédacteur va être notifié.");

    return response()->json(['id' => $message->id]);
}

Le code ressemble à ce que nous avons déjà vu avec :

  • récupération de l’annonce qui correspond au message
  • envoie de la notification en passant en paramètre l’annonce et le message
  • suppression du message dans la base
  • préparation d’un texte pour l’alerte
  • réponse au client en renvoyant l’identifiant du message

Si tout se passe bien la page est rechargée et l’alerte est affichée :

Et l’auteur du message reçoit cet email :

Refuser un message

Dans le cas de refus d’un message on va faire apparaître en page modale un formulaire pour que l’adminstrateur justifie sa décision pour informer l’auteur du message.

MessageRepository

On va avoir beosin de récupérer un message à partir de son identifiant :

public function getById($id)
{
    return Message::findOrFail($id);
}

Notification MessageRefuse

On va prévoir aussi une notification pour le rédacteur pour le prévenir que son message a été refusé :

php artisan make:notification MessageRefuse

Avec ce code :

<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use App\Models\ { Ad, Message };

class MessageRefuse extends Notification
{
    use Queueable;

    protected $message;
    protected $messageRefus;
    protected $ad;

    public function __construct(Ad $ad, Message $message, $messageRefus)
    {
        $this->ad = $ad;
        $this->message = $message;
        $this->messageRefus = $messageRefus;
    }

    public function via($notifiable)
    {
        return ['mail'];
    }

    public function toMail($notifiable)
    {
        return (new MailMessage)
                    ->line('Nous avons refusé ce message que vous avez déposé :')
                    ->line('--------------------------------------')
                    ->line($this->message->texte)
                    ->line('--------------------------------------')
                    ->line('Pour la raison suivante :')
                    ->line('--------------------------------------')
                    ->line($this->messageRefus)
                    ->line('--------------------------------------')
                    ->action('Voir cette annonce', route('annonces.show', $this->ad->id))
                    ->line("Merci d'utiliser notre site pour vos annonces !");
    }

    public function toArray($notifiable)
    {
        return [
            //
        ];
    }
}

AdminController

Il ne nous reste plus qu’à coder le contrôleur :

public function MessageRefuse(MessageRefuseRequest $request)
{
    $message = $this->messagerepository->getById($request->id);

    $ad = $this->messagerepository->getAd($message);

    $ad->notify(new MessageRefuse($ad, $message, $request->message));

    $this->messagerepository->delete($message);

    $request->session()->flash('status', "Le message a bien été refusé et le rédacteur va être notifié.");

    return response()->json(['id' => $ad->id]);
}

Là on :

  • récupère le message à partir de son identifiant
  • récupère l’annonce qui correspond à ce message
  • notifie l’auteur du message du refus
  • supprime le message dans la base
  • prépare le texte de l’alerte
  • retourne l’indentifiant de l’annonce au client

Fonctionnement

Voyons maintenant le fonctionnement pour l’approbation d’une annonce…

Dans le tableau pour le lien de refus on a ce code :

<a class="btn btn-danger btn-sm" href="#" role="button" data-id="{{ $message->id }}" data-toggle="tooltip" title="Refuser le message"><i class="fas fa-thumbs-down"></i></a>

L’action va ouvrir une page modale :

$('.btn-danger').click((e) => {
    e.preventDefault();
    $('#id').val($(e.currentTarget).attr('data-id'));
    $('#messageModal').modal();
});

C’est la même page et le même code qu’on a vu pour l’envoi d’un message lors du refus d’une annonce.

Je ne détaille donc pas ce fonctionnement. L’adminsitrateur obtient ce message :

Le rédacteur reçoit cet email :

Conclusion

On en a terminé avec l’administration du site. On peut désormais accepter ou refuser annonce et message. On peut également prolonger la durée publication d’une annonce. Dans le prochain article on mettra en place la gestion du profil de l’utilisateur en lui donnant accès à ses données et à ses annonces.

 

 




Un site d’annonces – l’administration (1/2)

Maintenant que la partie client est pratiquement terminée nous allons nous consacrer à l’administration du site. L’administrateur a pour tâche de modérer les annonces et certains messages, d’autre part il doit gérer les annonces obsolètes.

Pour vous simplifier la vie vous pouvez télécharger le dossier complet pour le code de cet article.

sb-admin-2

Pour ce projet j’ai opté pour sb-admin-2 comme template :

Il est basé sur Bootstrap 4 qu’on utilise déjà et je le trouve plutôt esthétique.

Vous pouvez télécharger les fichiers à partir du site. Ensuite vous copier le contenu du dossier scss dans un dossier sb-admin-2 de notre projet :

De la même manière on copie le fichier Javascript sb-admin-2.js dans le projet :

Au début de ce ficheir on va ajouter l’appel à Popper, JQuery et Bootstrap :

window.Popper = require('popper.js').default;
window.$ = window.jQuery = require('jquery');
require('bootstrap');

...

Enfin pour compiler tout ça on complète le fichier webpack.mix.js :

mix.js('resources/js/sb-admin-2.js', 'public/js')
   .sass('resources/sass/sb-admin-2/sb-admin-2.scss', 'public/css');

Et on compile !

Nos assets sont maintenant prêts…

Routes et contrôleur

Pour l’administration on va créer un contrôleur :

php artisan make:controller AdminController

On va déjà créer la méthode index et déclarer les deux repositories qu’on a déjà créés :

<?php

namespace App\Http\Controllers;

use Carbon\Carbon;
use Illuminate\Http\Request;
use App\Repositories\ { AdRepository, MessageRepository };

class AdminController extends Controller
{
    protected $adRepository;
    protected $messagerepository;

    public function __construct(AdRepository $adRepository, Messagerepository $messagerepository)
    {
        $this->adRepository = $adRepository;
        $this->messagerepository = $messagerepository;
    }

    public function index()
    {
        return view('admin.index');
    }
}

On ajoute aussi une route en préparant un groupe pour l’administration :

Route::prefix('admin')->middleware('admin')->group(function () {
    Route::get('/', 'AdminController@index')->name('admin.index');
});

On crée le middleware admin :

php artisan make:middleware Admin

Avec ce code :

public function handle($request, Closure $next)
{
    $user = $request->user();

    if ($user && $user->admin) {
        return $next($request);
    }

    return redirect()->route('home');
}

Et on le déclare dans le Kernel :

protected $routeMiddleware = [
    ...
    'admin' => \App\Http\Middleware\Admin::class,
];

On va se contenter de ça pour le moment.

L’accès à l’administration

On va prévoir un accès à l’administration à partir de la page d’accueil dans la barre de menu.

Dans le provider AppServiceProvider on va ajouter une instruction pour Blade pour sélectionner les administrateurs. J’en profite pour ajouter la francisation de la ressource que j’ai oubliée dans un précédent article :

use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;

...


public function boot()
{
    Blade::if ('admin', function () {
        return auth()->check() && auth()->user()->admin;
    });

    Route::resourceVerbs([
        'create' => 'creer',
        'edit' => 'modifier',
    ]);
}

Dans le layout app on ajoute le lien de l’administration en le conditionnant avec l’instruction qu’on vient de créer :

<div class="collapse navbar-collapse" id="navbarSupportedContent">
    <!-- Right Side Of Navbar -->
    <ul class="navbar-nav ml-auto">
        <!-- Authentication Links -->
        @guest
            <li class="nav-item">
                <a class="nav-link" href="{{ route('login') }}">Connexion</a>
            </li>
        @else
            @admin
                <li class="nav-item">
                    <a class="nav-link" href="{{ route('admin.index') }}">Administration</a>
                </li>
            @endadmin
            <li class="nav-item">
                <a class="nav-link" href="{{ route('logout') }}"
                    onclick="event.preventDefault(); document.getElementById('logout-form').submit();">
                    Déconnexion
                </a>
                <form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
                    @csrf
                </form>
            </li>
        @endguest
    </ul>
</div>

Le layout de l’administration

On crée un layout (admin) pour les vues de l’administration accompagné de deux vues pour le haut de la page (back-head) et le base de la page (back-footer) :

Cette organisation est due au fait qu’on utilisera aussi les deux vues annexes pour le profil utilisateur.

Vue back-head

Voici le code de l’entête :

<!DOCTYPE html>
<html lang="fr">

<head>

  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <meta name="description" content="">
  <meta name="author" content="">

  <!-- CSRF Token -->
  <meta name="csrf-token" content="{{ csrf_token() }}">

  <title>Annonces</title>

  <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css">
  <link href="https://fonts.googleapis.com/css?family=Nunito:200,200i,300,300i,400,400i,600,600i,700,700i,800,800i,900,900i" rel="stylesheet">

  <link href="{{ asset('css/sb-admin-2.css') }}" rel="stylesheet">

</head>

Vue back-footer

Voici le code du bas de page :

<script src="{{ asset('js/sb-admin-2.js') }}"></script>
<script>
    $(function () {
        $('[data-toggle="tooltip"]').tooltip();
        $.ajaxSetup({
            headers: {
                'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
            }
        });
    })
</script>
@yield('script')

</body>

</html>

Vue admin

Et enfin le layout dont le code est extrait de celui de sb-admin-2 :

@include('layouts.back-head')

<body id="page-top">

    <!-- Page Wrapper -->
    <div id="wrapper">

      <!-- Sidebar -->
      <ul class="navbar-nav bg-gradient-primary sidebar sidebar-dark accordion" id="accordionSidebar">

        <!-- Sidebar - Brand -->
        <a class="sidebar-brand d-flex align-items-center justify-content-center" href="{{ url('/') }}">
          <div class="sidebar-brand-icon rotate-n-15">
            <i class="fab fa-earlybirds fa-2x"></i>
          </div>
          <div class="sidebar-brand-text mx-3">Annonces</div>
        </a>

        <!-- Divider -->
        <hr class="sidebar-divider my-0">

        <!-- Nav Item - Dashboard -->
        <li class="nav-item @if(request()->route()->getName() == 'admin.index') active @endif">
          <a class="nav-link" href="{{ route('admin.index') }}">
            <i class="fas fa-fw fa-tachometer-alt"></i>
            <span>Panneau</span></a>
        </li>

        <!-- Divider -->
        <hr class="sidebar-divider">

        <!-- Heading -->
        <div class="sidebar-heading">
          Annonces
        </div>

        <li class="nav-item @if(request()->route()->getName() == 'admin.ads') active @endif">
            <a class="nav-link" href="">
                <i class="fas fa-fw fa-question"></i>
            <span>A modérer</span></a>
        </li>

        <li class="nav-item @if(request()->route()->getName() == 'admin.obsoletes') active @endif">
            <a class="nav-link" href="">
                <i class="fas fa-fw fa-hourglass-end"></i>
            <span>Obsolètes</span></a>
        </li>

        <!-- Divider -->
        <hr class="sidebar-divider">

        <!-- Heading -->
        <div class="sidebar-heading">
          Messages
        </div>

        <li class="nav-item @if(request()->route()->getName() == 'admin.messages') active @endif">
            <a class="nav-link" href="">
                <i class="fas fa-fw fa-question"></i>
            <span>A modérer</span></a>
        </li>

        <!-- Divider -->
        <hr class="sidebar-divider d-none d-md-block">

        <!-- Sidebar Toggler (Sidebar) -->
        <div class="text-center d-none d-md-inline">
          <button class="rounded-circle border-0" id="sidebarToggle"></button>
        </div>

      </ul>
      <!-- End of Sidebar -->

      <!-- Content Wrapper -->
      <div id="content-wrapper" class="d-flex flex-column">

        <!-- Main Content -->
        <div id="content">

        <!-- Topbar -->
        <nav class="navbar navbar-expand navbar-light bg-white topbar mb-4 static-top shadow">

            <!-- Sidebar Toggle (Topbar) -->
            <button id="sidebarToggleTop" class="btn btn-link d-md-none rounded-circle mr-3">
              <i class="fa fa-bars"></i>
            </button>

            <!-- Topbar Navbar -->
            <ul class="navbar-nav ml-auto">

              <!-- Nav Item - User Information -->
              <li class="nav-item dropdown no-arrow">
                <a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                  <span class="d-lg-inline text-gray-600 small">{{ auth()->user()->name }}</span>
                </a>
                <!-- Dropdown - User Information -->
                <div class="dropdown-menu dropdown-menu-right shadow animated--grow-in" aria-labelledby="userDropdown">
                  <div class="dropdown-divider"></div>
                  <a class="dropdown-item" href="{{ route('logout') }}"
                        onclick="event.preventDefault(); document.getElementById('logout-form').submit();">
                    <i class="fas fa-sign-out-alt fa-sm fa-fw mr-2 text-gray-400"></i>
                    Déconnexion
                  </a>
                  <form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
                    @csrf
                  </form>
                </div>
              </li>

            </ul>

          </nav>
          <!-- End of Topbar -->

          <!-- Begin Page Content -->
          <div class="container-fluid">

            @yield('content')

          </div>
          <!-- /.container-fluid -->

        </div>
        <!-- End of Main Content -->

        <!-- Footer -->
        <footer class="sticky-footer bg-white">
          <div class="container my-auto">
            <div class="copyright text-center my-auto">
              <span>Copyright &copy; Annonces 2019</span>
            </div>
          </div>
        </footer>
        <!-- End of Footer -->

      </div>
      <!-- End of Content Wrapper -->

    </div>
    <!-- End of Page Wrapper -->

    @include('layouts.back-footer')

Vue index

Enfin on crée un dossier admin et la vue index qui est l’accueil de l’administration :

@extends('layouts.admin')

@section('content')

    <!-- Page Heading -->
    <div class="d-sm-flex align-items-center justify-content-between mb-4">
        <h1 class="h3 mb-0 text-gray-800">Tableau de bord</h1>
    </div>

    <!-- Content Row -->
    <div class="row">

        <div class="col-xl-4 col-md-6 mb-4">
        <div class="card border-left-primary shadow h-100 py-2">
            <div class="card-body">
            <div class="row no-gutters align-items-center">
                <div class="col mr-2">
                <div class="text-xs font-weight-bold text-primary text-uppercase mb-1">Annonces à modérer</div>
                <div class="h5 mb-0 font-weight-bold text-gray-800"></div>
                </div>
                <div class="col-auto">
                <i class="fas fa-cog fa-2x text-gray-300"></i>
                </div>
            </div>
            </div>
        </div>
        </div>

        <div class="col-xl-4 col-md-6 mb-4">
        <div class="card border-left-primary shadow h-100 py-2">
            <div class="card-body">
            <div class="row no-gutters align-items-center">
                <div class="col mr-2">
                <div class="text-xs font-weight-bold text-primary text-uppercase mb-1">Messages à modérer</div>
                <div class="h5 mb-0 font-weight-bold text-gray-800"></div>
                </div>
                <div class="col-auto">
                <i class="fas fa-cog fa-2x text-gray-300"></i>
                </div>
            </div>
            </div>
        </div>
        </div>

        <div class="col-xl-4 col-md-6 mb-4">
        <div class="card border-left-warning shadow h-100 py-2">
            <div class="card-body">
            <div class="row no-gutters align-items-center">
                <div class="col mr-2">
                <div class="text-xs font-weight-bold text-warning text-uppercase mb-1">Annonces obsolètes</div>
                <div class="h5 mb-0 font-weight-bold text-gray-800"></div>
                </div>
                <div class="col-auto">
                <i class="fas fa-hourglass-end fa-2x text-gray-300"></i>
                </div>
            </div>
            </div>
        </div>

    </div>

@endsection

L’accueil

Maintenant lorsqu’un administrateur de connecte il a le lien vers l’adminstration sur la page d’accueil du site :

Et si on clique on arrive enfin sur la page de l’administration :

On a maintenant notre squelette en place, il ne reste plus qu’à coder tout ça !

On va commencer par renseigner les cartes avec le nombre d’éléments de chaque catégorie.

AdRepository

Dans ce repository on va ajouter ces deux méthodes pour le comptage des annonces non actives et obsolètes :

public function noActiveCount($ads = null)
{
    if($ads) {
        return $ads->where('active', false)->count();
    }
    return Ad::where('active', false)->count();
}


public function obsoleteCount($ads = null)
{
    if($ads) {
        return $ads->where('active', true)->where('limit', '<', Carbon::now())->count();
    }
    return Ad::where('limit', '<', Carbon::now())->count();
}

MessageRepository

De la même manière on crée cette méthode dans ce repository pour compter les message à modérer :

public function count()
{
    return Message::count();
}

AdminController

On met à jour la méthode index du contrôleur :

public function index()
{
    $adModerationCount = $this->adRepository->noActiveCount();
    $adPerimesCount = $this->adRepository->obsoleteCount();
    $messageModerationCount = $this->messagerepository->count();

    return view('admin.index', compact('adModerationCount', 'messageModerationCount', 'adPerimesCount'));
}

Vue index

Il ne nous reste plus qu’à ajouter les variables dans la vue index :

...
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ $adModerationCount }}</div>
...
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ $messageModerationCount }}</div>
...
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ $adPerimesCount }}</div>
...

Maintenant on obtient le nombre pour chaque catégorie :

Annonces à modérer

Les routes

On doit ajouter des routes pour la modération des annonces :

  • accès à la liste
  • approbation
  • refus

Les voici :

Route::prefix('admin')->middleware('admin')->group(function () {
    ...
    Route::prefix('annonces')->group(function () {
        Route::get('/', 'AdminController@ads')->name('admin.ads');
        Route::middleware('ajax')->group(function () {
            Route::post('approve/{ad}', 'AdminController@approve')->name('admin.approve');
            Route::post('refuse', 'AdminController@refuse')->name('admin.refuse');
        });
    });
});

On avait déjà le groupe admin, on y inclus un groupe annonces. On voit que l’approbation et le refus se feront en ajax, on utilise le middleware qu’on a déjà créé.

AdRespository

Dans le repository on ajoute une méthode pour récupérer les annonces non actives paginées classées par date :

public function noActive($nbr)
{
    return Ad::whereActive(false)->latest()->paginate($nbr);
}

AdminController

Dans le contrôleur on utilise le repository pour envoyer les données dans la vue :

public function ads()
{
    $adModeration = $this->adRepository->noActive(5);
    return view('admin.ads', compact('adModeration'));
}

Vue admin.ads

On crée la vue ads :

Avec ce code :

@extends('layouts.admin')

@section('content')

    @include('partials.message', ['url' => route('admin.refuse')])

    @include('partials.alerts', ['title' => 'Annonces à modérer'])

    <div class="table-responsive">
        <table class="table table-hover">
            <thead class="thead-light">
                <tr>
                    <th scope="col">Titre</th>
                    <th scope="col"></th>
                </tr>
            </thead>
            <tbody>
                @foreach ($adModeration as $ad)
                    <tr id="{{ $ad->id }}">
                        <td>{{ $ad->title }}</td>
                        <td class="float-right">
                            <a class="btn btn-primary btn-sm" href="{{ route('annonces.show', $ad->id) }}" target="_blank" role="button" data-toggle="tooltip" title="Voir l'annonce"><i class="fas fa-eye"></i></a>
                            <a class="btn btn-success btn-sm" href="{{ route('admin.approve', $ad->id) }}" role="button" data-toggle="tooltip" title="Approuver l'annonce"><i class="fas fa-thumbs-up"></i></a>
                            <i class="fas fa-spinner fa-pulse fa-lg" style="display: none"></i>
                            <a class="btn btn-danger btn-sm" href="#" role="button" data-id="{{ $ad->id }}" data-toggle="tooltip" title="Refuser l'annonce"><i class="fas fa-thumbs-down"></i></a>
                        </td>
                    </tr>
                @endforeach
            </tbody>
        </table>
    </div>

    <div class="d-flex">
        <div class="mx-auto">
            {{ $adModeration->links() }}
        </div>
    </div>

@endsection

@section('script')

    @include('partials.script')

@endsection

On fait appel à quelques vues partielles. On a déjà celle pour les messages, on va créer les deux autres :

Vue partielle alerts

Cette vue est destinée à afficher les alertes :

<div class="d-sm-flex align-items-center justify-content-between mb-4">
    <h1 class="h3 mb-0 text-gray-800">{{ $title }}</h1>
</div>

@if(session()->has('status'))
    <div class="alert alert-success alert-dismissible fade show" role="alert">
        {{ session('status') }}
        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
            <span aria-hidden="true">&times;</span>
        </button>
    </div>
@endif

<div class="alert alert-warning alert-dismissible fade d-none" role="alert">
    Le serveur est inaccessible, veuillez retenter dans quelques minutes.
    <button type="button" class="close" aria-label="Close">
        <span aria-hidden="true">&times;</span>
    </button>
</div>

Vue partielle script

Dans cette vue on a le Javascript pour gérer les requêtes HTML, les alertes :

<script>

    $(() => {

        const toggleButtons = () => {
            $('#icon').toggle();
            $('#buttons').toggle();
        }

        $('.alert-success button').click(() => {
            $('.alert-success').addClass('d-none').removeClass('show');
        });

        $('.alert-warning button').click(() => {
            $('.alert-warning').addClass('d-none').removeClass('show');
        });

        $('.btn-success').click((e) => {
            e.preventDefault();
            let that = $(e.currentTarget);
            that.hide();
            that.closest('td').find('i.fa-spinner').show();
            $.post(that.attr('href'))
            .done((data) => {
                document.location.reload(true);
            })
            .fail(() => {
                $('.alert-warning').removeClass('d-none').addClass('show');
                that.show();
                that.closest('td').find('i.fa-spinner').hide();
            });
        });

        $('.btn-danger').click((e) => {
            e.preventDefault();
            $('#id').val($(e.currentTarget).attr('data-id'));
            $('#messageModal').modal();
        });

        $('#messageForm').submit((e) => {
            let that = e.currentTarget;
            e.preventDefault();
            $('#message').removeClass('is-invalid');
            $('.invalid-feedback').html('');
            toggleButtons();
            $.ajax({
                method: $(that).attr('method'),
                url: $(that).attr('action'),
                data: $(that).serialize()
            })
            .done((data) => {
                document.location.reload(true);
            })
            .fail((data) => {
                toggleButtons();
                if(data.status == 422) {
                    $.each(data.responseJSON.errors, function (i, error) {
                        $(document)
                            .find('[name="' + i + '"]')
                            .addClass('is-invalid')
                            .next()
                            .append(error[0]);
                    });
                } else {
                    $('#messageModal').modal('hide');
                    $('.alert-warning').removeClass('d-none').addClass('show');
                }
            });
        });
    })

</script>

Layout admin

Dans notre layout admin il faut ajouter le lien pour accéder à la vue des annonces à modérer (on n’avait pas renseigné le href) :

<li class="nav-item @if(request()->route()->getName() == 'admin.ads') active @endif">
    <a class="nav-link" href="{{ route('admin.ads') }}">
        <i class="fas fa-fw fa-question"></i>
    <span>A modérer</span></a>
</li>

Le lien devient alors actif et en cliquant on arrive sur le page des annonces à modérer :

Pour chaque annonce on dispose de 3 icônes de commande pour :

  • voir l’annonce
  • approuver l’annonce
  • refuser l’annonce

Le codage existait déjà pour la première action et nous n’avons donc à nous intéresser aux deux dernières.

Approuver une annonce

On va coder maintenant côté serveur pour l’approbation d’une annonce.

AdRepository

Approuver une annonce signifie mettre true dans le champ active. On crée donc une méthode dans le repository pour le faire :

public function approve($ad)
{
    $ad->active = true;
    $ad->save();
}

Notification AdApprove

On va prévoir aussi une notification pour le rédacteur pour le prévenir que son annonce a été approuvée :

php artisan make:notification AdApprove

Avec ce code :

<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use App\Models\Ad;

class AdApprove extends Notification
{
    use Queueable;

    protected $ad;

    public function __construct(Ad $ad)
    {
        $this->ad = $ad;
    }

    public function via($notifiable)
    {
        return ['mail'];
    }

    public function toMail($notifiable)
    {
        return (new MailMessage)
                    ->line('Nous avons approuvé une annonce que vous avez déposée :')
                    ->action('Voir votre annonce', route('annonces.show', $this->ad->id))
                    ->line("Merci d'utiliser notre site pour vos annonces !");
    }

    public function toArray($notifiable)
    {
        return [
            //
        ];
    }
}

AdminController

Il ne nous reste plus qu’à coder le contrôleur :

use App\Models\Ad;
use App\Notifications\AdApprove;

...

public function approve(Request $request, Ad $ad)
{
    $this->adRepository->approve($ad);

    $request->session()->flash('status', "L'annonce a bien été approuvée et le rédacteur va être notifié.");

    $ad->notify(new AdApprove($ad));

    return response()->json(['id' => $ad->id]);
}

Fonctionnement

Voyons maintenant le fonctionnement pour l’approbation d’une annonce…

Dans la vue ads pour le lien d’approbation on a ce code :

<a class="btn btn-success btn-sm" href="{{ route('admin.approve', $ad->id) }}" role="button" data-toggle="tooltip" title="Approuver l'annonce"><i class="fas fa-thumbs-up"></i></a>

L’url générée est de la forme admin/annonces/approve/{annonce_id}. Comme on est en Ajax c’est le Javascript qui gère la requête :

$('.btn-success').click((e) => {
    e.preventDefault();
    let that = $(e.currentTarget);
    that.hide();
    that.closest('td').find('i.fa-spinner').show();
    $.post(that.attr('href'))
    .done((data) => {
        document.location.reload(true);
    })
    .fail(() => {
        $('.alert-warning').removeClass('d-none').addClass('show');
        that.show();
        that.closest('td').find('i.fa-spinner').hide();
    });
});

Pendant la durée de la communication avec le serveur l’icône d’approbation est remplacée par une icône d’attente animée :

Dans le contrôleur on traite la requête avec ce code :

public function approve(Request $request, Ad $ad)
{
    $this->adRepository->approve($ad);

    $request->session()->flash('status', "L'annonce a bien été approuvée et le rédacteur va être notifié.");

    $ad->notify(new AdApprove($ad));

    return response()->json(['id' => $ad->id]);
}

On approuve l’annonce, on prépare un message en flash session, on envoie la notification au rédacteur, on renvoie l’identifiant de l’annonce au client.

Si le serveur ne renvoie pas d’erreur la page est rechargée, ce qui actualise la liste des annonces, et l’alerte s’affiche :

Avec ce message :

Si le serveur retourne une erreur on affiche cette autre alerte :

Refuser une annonce

On va coder maintenant côté serveur pour le refus d’une annonce.

AdRepository

Refuser une annonce signifie la supprimer définitivement. On crée donc une méthode dans le repository pour le faire :

public function delete($ad)
{
    $ad->delete();
}

Notification AdRefuse

On va prévoir aussi une notification pour le rédacteur pour le prévenir que son annonce a été refusée :

php artisan make:notification AdRefuse

Avec ce code :

<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;

class AdRefuse extends Notification
{
    use Queueable;

    protected $message;


    public function __construct($message)
    {
        $this->message = $message;
    }


    public function via($notifiable)
    {
        return ['mail'];
    }


    public function toMail($notifiable)
    {
        return (new MailMessage)
                    ->line('Nous avons refusé une annonce que vous avez déposée pour la raison suivante :')
                    ->line($this->message)
                    ->line("Merci d'utiliser notre site pour vos annonces !");
    }


    public function toArray($notifiable)
    {
        return [
            //
        ];
    }
}

MessageRefuseRequest

Comme on va devoir envoyer un message à partir d’un formulaire on a besoin d’une Form Request :

php artisan make:request MessageRefuse

Qu’on code ainsi :

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class MessageRefuse extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'message' => ['required', 'string', 'max:500'],
        ];
    }
}

AdminController

Il ne nous reste plus qu’à coder le contrôleur :

use App\Notifications\ { AdApprove, AdRefuse };
use App\Http\Requests\MessageRefuse as MessageRefuseRequest;

...

public function refuse(MessageRefuseRequest $request)
{
    $ad = $this->adRepository->getById($request->id);

    $ad->notify(new AdRefuse($request->message));

    $this->adRepository->delete($ad);

    $request->session()->flash('status', "L'annonce a bien été refusée et le rédacteur va être notifié.");

    return response()->json(['id' => $ad->id]);
}

Fonctionnement

Voyons maintenant le fonctionnement pour l’approbation d’une annonce…

Dans la vue ads pour le lien de refus on a ce code :

<a class="btn btn-danger btn-sm" href="#" role="button" data-id="{{ $ad->id }}" data-toggle="tooltip" title="Refuser l'annonce"><i class="fas fa-thumbs-down"></i></a>

L’action va ouvrir une page modale :

$('.btn-danger').click((e) => {
    e.preventDefault();
    $('#id').val($(e.currentTarget).attr('data-id'));
    $('#messageModal').modal();
});

En effet il faut expliquer au rédacteur pourquoi on refuse l’annonce.

La soumission est assurée par Javascript :

$('#messageForm').submit((e) => {
    let that = e.currentTarget;
    e.preventDefault();
    $('#message').removeClass('is-invalid');
    $('.invalid-feedback').html('');
    toggleButtons();
    $.ajax({
        method: $(that).attr('method'),
        url: $(that).attr('action'),
        data: $(that).serialize()
    })
    .done((data) => {
        document.location.reload(true);
    })
    .fail((data) => {
        toggleButtons();
        if(data.status == 422) {
            $.each(data.responseJSON.errors, function (i, error) {
                $(document)
                    .find('[name="' + i + '"]')
                    .addClass('is-invalid')
                    .next()
                    .append(error[0]);
            });
        } else {
            $('#messageModal').modal('hide');
            $('.alert-warning').removeClass('d-none').addClass('show');
        }
    });
});

Le traitement de l’échec (fail) distingue deux cas :

  • erreur de validation (code 422), dans ce cas on affiche l’erreur
  • autre erreur du serveur, dans ce cas on efface la page modale et on affiche une alerte

La requête arrive dans la méthode refuse du contrôleur. La Form request assure la validation et on renvoie une erreur 422 avec le message de l’erreur au besoin :

Comme pour l’approbation de l’annonce on a des alertes de réussite ou d’échec.

Avec la notification :

Conclusion

Dans cette première partie de construciton de l’administration on a mis en place la structure de base et la page d’accueil. Nous avons aussi traité le cas de l’approbation et du refus des annonces. Dans le prochain article on terminera avec la gestion des annonces obsolètes et la modération des messages.




Un site d’annonces – créer une annonce

Dans cet article nous allons maintenant mettre en place l’intendance pour la création d’une annonce. La principale difficulté va résider dans la gestion des photos. Heureusement pour nous il existe Dropzone, une librairie Javascript qui facilite la mise en place d’un système de glisser-déposer des fichiers.

Pour vous simplifier la vie vous pouvez télécharger le dossier complet pour le code de cet article.

Dropzone et Intervention image

Pour l’intégration de Dropzone dans Laravel je me suis largement inspiré de cet article et de celui-ci.

On commence par charger Dropzone avec npm :

npm i dropzone --save-dev

Pour le fonctionnement de Dropzone il faut prévoir la librairie dans le CSS. Voici le fichier resources\sass\app.scss complété en conséquence (je le remets complet parce que j’ai ajouté quelques règles spécifiques pour dropzone) :

// Fonts
@import url('https://fonts.googleapis.com/css?family=Sniglet');

// Dropzone
@import "~dropzone/dist/dropzone.css";

// Bootstrap
@import '~bootstrap/scss/bootstrap';

html {
    font-size: 0.8rem;
}

@include media-breakpoint-up(sm) {
    html {
        font-size: 1rem;
    }
}

@include media-breakpoint-up(lg) {
    html {
        font-size: 1.4rem;
    }
}

body {
    background-color: #e47517;
    Sniglet', cursive;
}

.navbar {
    background-color: #e4751788;
}

.navbar-expand-md {
    @include font-size(1.4rem);
}

.navbar-expand {
    padding: 0;
    font-size: 0.8rem;
}

.base {
    fill:#003dd4;
    cursor: pointer;
    -webkit-transition: fill .5s ease-out;
    -moz-transition: fill .5s ease-out;
    -o-transition: fill .5s ease-out;
    transition: fill .5s ease-out;
}

.base:hover {
    fill: #bd4;
}

#my-dropzone {
    border: 2px dashed #0087F7;
    border-radius: 5px;
    .message {
        Segoe UI Light", "Arial", serif;
        font-weight: 600;
        color: #0087F7;
        @include font-size(1.5em);
        letter-spacing: 0.05em;
    }
}

a.dz-remove {
    text-decoration: none !important;
    &:hover {
        color: red;
        font-size: 1rem !important;
    }
}

a.blockAd {
    text-decoration: none;
    color: inherit;
    &:hover h4 {
        color: #e47517;
    }
    &:hover .card {
        box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
    }
}

#accordion .btn-link a {
    text-decoration: none;
}

Il faut aussi ajouté la partie Javascript dans resources\js\app.js :

window.Popper = require('popper.js').default;
window.$ = window.jQuery = require('jquery');
window.Dropzone = require('dropzone');
require('bootstrap');

Et on recompile !

Pour la gestion côté serveur on va ajouter Intervention Image qui est un incontournable sur le sujet :

composer require intervention/image

Il n’y a rien de plus à faire parce que la librairie est automatiquement reconnue par Laravel.

Routes et contrôleur

Pour les routes on en a déjà puisqu’on a prévu un contrôleur de ressource (AdController) avec ce code :

Route::resource('annonces', 'AdController')
    ->parameters([
        'annonces' => 'ad'
    ])->except([
        'index', 'show', 'destroy'
]);

On dispose donc du create et du store.

Mais on va avoir besoin de routes pour les photos. En effet chaque fois qu’une photo est déposée dans la zone de téléchargement elle est envoyée au serveur qui doit mémoriser à la fois la photo et la session correspondante. En tenant compte du fait que l’utilisateur peut aussi supprimer une photo on comprend que l’intendance n’est pas des plus faciles. se rajoute une autre difficulté : si l’utilisateur recharge la page il faut actualiser la zone des photos téléchargées !

On a donc besoin de ces 3 routes :

Route::middleware('ajax')->group(function () {
    Route::post('images-save', 'UploadImagesController@store')->name('save-images');
    Route::delete('images-delete', 'UploadImagesController@destroy')->name('destroy-images');
    Route::get('images-server','UploadImagesController@getServerImages')->name('server-images');
    ...
});
  • save-image : pour télécharger une photo
  • destroy-image : pour supprimer une photo
  • server-images : pour récupérer toutes les images téléchargées en cas de rechargement de la page

Pour ces 3 actions on va créer le contrôleur UploadImagesController :

php artisan make:controller UploadImagesController

On vérifie nos routes :

On va commencer par coder la méthode create de AdController :

use Illuminate\Support\Str;

...


public function create(Request $request)
{
    if(!$request->session()->has('index')) {
        $request->session()->put('index', Str::random(30));
    }

    $categories = Category::select('name', 'id')->oldest('name')->get();
    $regions = Region::oldest('name')->get();

    return view('create', compact('categories', 'regions'));
}

Si on n’a pas de référence en session on en crée une (index).

Pour le formulaire de création des annonces on a besoin de la liste des catégories et de celle des régions.

On va aussi ajouter l’adresse du lien pour le bouton de création d’une annonce dans la vue home :

<a class="btn btn-primary" href="{{ route('annonces.create') }}" role="button">Déposer une annonce</a>

La vue create

On crée la vue create ici :

Avec ce code :

@extends('layouts.app')

@section('content')

<div class="container">

    @guest
        <div class="alert alert-warning text-center alert-dismissible fade show" role="alert">
            Vous n'êtes pas connecté, vous ne pourrez pas modifier votre annonce ultérieurement<br>
            La création d'un compte est gratuite et vous offre de nombreuses fonctionnalités
            <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                <span aria-hidden="true">&times;</span>
            </button>
        </div>
    @endguest

    <div class="card bg-light">
        <h5 class="card-header">Votre annonce</h5>
        <div class="card-body">

            <div class="form-group">
                <label>Vos photos (3 images au maximum et 3Mo pour chacune)</label>
                <form method="post" action="{{ route('save-images') }}" enctype="multipart/form-data" class="dropzone" id="my-dropzone">
                    @csrf
                    <div class="dz-message">
                        <div class="col-xs-8">
                            <div class="message">
                                <p>Déposez vos photos ici ou cliquez</p>
                            </div>
                        </div>
                    </div>
                </form>
            </div>

            <form method="POST" action="{{ route('annonces.store') }}">
                @csrf

                <div class="form-group">
                    <label for="category">Catégorie</label>
                    <select class="custom-select" name="category" id="category">
                        @foreach ($categories as $category)
                            <option value="{{ $category->id }}" @if($loop->first) selected @endif>{{ $category->name }}</option>
                        @endforeach
                    </select>
                </div>

                @include('partials.form-group', [
                    'title' => 'Titre',
                    'type' => 'text',
                    'name' => 'title',
                    'required' => true,
                ])

                <div class="form-group">
                    <label for="texte">Texte</label>
                    <textarea class="form-control{{ $errors->has('texte') ? ' is-invalid' : '' }}" id="texte" name="texte" rows="3" required>{{ old('texte', isset($value) ? $value : '') }}</textarea>
                    @if ($errors->has('texte'))
                        <div class="invalid-feedback">
                            {{ $errors->first('texte') }}
                        </div>
                    @endif
                </div>

                <div class="form-group">
                    <label for="limit">Nombre de semaines de parution</label>
                    <select class="custom-select" id="limit" name="limit">
                        <option value="1">1</option>
                        <option value="2">2</option>
                        <option value="3">3</option>
                        <option value="4">4</option>
                    </select>
                </div>

                <br>

                <div class="card">
                    <h5 class="card-header">Votre localisation</h5>
                    <div class="card-body">
                        <div class="form-group">
                            <label for="region">Région</label>
                            <select class="custom-select" name="region" id="region">
                                @foreach ($regions as $region)
                                    <option data-code="{{ $region->code }}" value="{{ $region->id }}" @if($loop->first) selected @endif>{{ $region->name }}</option>
                                @endforeach
                            </select>
                        </div>

                        <div class="form-group">
                            <label for="departement">Département</label>
                            <select class="custom-select" name="departement" id="departement"></select>
                        </div>

                        <div class="form-group">
                            <label for="commune">Commune</label>
                            <select class="custom-select" name="commune" id="commune"></select>
                        </div>
                    </div>
                </div>

                <br>

                @guest
                    <div class="card">
                        <h5 class="card-header">Votre identité</h5>
                        <div class="card-body">

                            @include('partials.form-group', [
                                'title' => 'Pseudo',
                                'type' => 'text',
                                'name' => 'pseudo',
                                'required' => true,
                            ])

                            @include('partials.form-group', [
                                'title' => 'Email',
                                'type' => 'email',
                                'name' => 'email',
                                'required' => true,
                            ])

                        </div>
                    </div>
                @endguest

                <br>

                <button type="submit" class="btn btn-primary">Valider</button>
            </form>

            <div id="preview" style="display: none;">
                <div class="dz-preview dz-file-preview">
                    <div class="dz-image"><img data-dz-thumbnail /></div>
                    <div class="dz-details">
                        <div class="dz-size"><span data-dz-size></span></div>
                        <div class="dz-filename"><span data-dz-name></span></div>
                    </div>
                    <div class="dz-progress"><span class="dz-upload" data-dz-uploadprogress></span></div>
                    <div class="dz-error-message"><span data-dz-errormessage></span></div>
                    <div class="dz-success-mark">
                        <svg width="54px" height="54px" viewBox="0 0 54 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
                            <title>Check</title>
                            <defs></defs>
                            <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
                                <path d="M23.5,31.8431458 L17.5852419,25.9283877 C16.0248253,24.3679711 13.4910294,24.366835 11.9289322,25.9289322 C10.3700136,27.4878508 10.3665912,30.0234455 11.9283877,31.5852419 L20.4147581,40.0716123 C20.5133999,40.1702541 20.6159315,40.2626649 20.7218615,40.3488435 C22.2835669,41.8725651 24.794234,41.8626202 26.3461564,40.3106978 L43.3106978,23.3461564 C44.8771021,21.7797521 44.8758057,19.2483887 43.3137085,17.6862915 C41.7547899,16.1273729 39.2176035,16.1255422 37.6538436,17.6893022 L23.5,31.8431458 Z M27,53 C41.3594035,53 53,41.3594035 53,27 C53,12.6405965 41.3594035,1 27,1 C12.6405965,1 1,12.6405965 1,27 C1,41.3594035 12.6405965,53 27,53 Z" id="Oval-2" stroke-opacity="0.198794158" stroke="#747474" fill-opacity="0.816519475" fill="#FFFFFF" sketch:type="MSShapeGroup"></path>
                            </g>
                        </svg>
                    </div>
                    <div class="dz-error-mark">
                        <svg width="54px" height="54px" viewBox="0 0 54 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
                            <title>Erreur</title>
                            <defs></defs>
                            <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
                                <g id="Check-+-Oval-2" sketch:type="MSLayerGroup" stroke="#747474" stroke-opacity="0.198794158" fill="#FFFFFF" fill-opacity="0.816519475">
                                    <path d="M32.6568542,29 L38.3106978,23.3461564 C39.8771021,21.7797521 39.8758057,19.2483887 38.3137085,17.6862915 C36.7547899,16.1273729 34.2176035,16.1255422 32.6538436,17.6893022 L27,23.3431458 L21.3461564,17.6893022 C19.7823965,16.1255422 17.2452101,16.1273729 15.6862915,17.6862915 C14.1241943,19.2483887 14.1228979,21.7797521 15.6893022,23.3461564 L21.3431458,29 L15.6893022,34.6538436 C14.1228979,36.2202479 14.1241943,38.7516113 15.6862915,40.3137085 C17.2452101,41.8726271 19.7823965,41.8744578 21.3461564,40.3106978 L27,34.6568542 L32.6538436,40.3106978 C34.2176035,41.8744578 36.7547899,41.8726271 38.3137085,40.3137085 C39.8758057,38.7516113 39.8771021,36.2202479 38.3106978,34.6538436 L32.6568542,29 Z M27,53 C41.3594035,53 53,41.3594035 53,27 C53,12.6405965 41.3594035,1 27,1 C12.6405965,1 1,12.6405965 1,27 C1,41.3594035 12.6405965,53 27,53 Z" id="Oval-2" sketch:type="MSShapeGroup"></path>
                                </g>
                            </g>
                        </svg>
                    </div>
                </div>
            </div>

        </div>
    </div>
@endsection

@section('script')
    <script>

        const fillSelect = (element, data) => {
            element.html('');
            data.forEach((e) => {
                element.append($('<option>').val(e.code).text(e.nom));
            });
        }

        const fillDepartements = () => {
            $.get('https://geo.api.gouv.fr/regions/' + $('#region option:selected').attr('data-code') + '/departements', function(data) {
                fillSelect($('#departement'), data);
                fillCommunes();
            });
        }

        const fillCommunes = () => {
            $.get('https://geo.api.gouv.fr/departements/' + $('#departement').val() + '/communes', function(data) {
                fillSelect($('#commune'), data);
            });
        }

        Dropzone.options.myDropzone = {
            uploadMultiple: true,
            parallelUploads: 3,
            maxFilesize: 3,
            maxFiles: 3,
            dictMaxFilesExceeded : 'Vous ne pouvez charger que 3 photos',
            previewTemplate: document.querySelector('#preview').innerHTML,
            addRemoveLinks: true,
            acceptedFiles: 'image/*',
            dictInvalidFileType : 'Type de fichier interdit',
            dictRemoveFile: 'Supprimer',
            dictFileTooBig: 'L\'image fait plus de 3 Mo',
            timeout: 10000,

            init () {

                const myDropzone = this;

                $.get('{{ route('server-images') }}', data => {
                    $.each(data.images, (key, value) => {
                        const mockFile = {
                            name: value.original,
                            size: value.size,
                            dataURL: '{{ url('images') }}' + '/' + value.server
                        };
                        myDropzone.files.push(mockFile);
                        myDropzone.emit("addedfile", mockFile);
                        myDropzone.createThumbnailFromUrl(mockFile,
                        myDropzone.options.thumbnailWidth,
                        myDropzone.options.thumbnailHeight,
                        myDropzone.options.thumbnailMethod, true, (thumbnail) => {
                            myDropzone.emit('thumbnail', mockFile, thumbnail);
                        });
                        myDropzone.emit('complete', mockFile);
                    });
                });

                this.on("removedfile", file => {
                    $.ajax({
                        method: 'delete',
                        url: '{{ route('destroy-images') }}',
                        data: { name: file.name, _token: $('[name="_token"]').val() }
                    });
                });
            }
        };

        $(() => {
            fillDepartements();
            $('#region').change(() => { fillDepartements(); });
            $('#departement').change(() => { fillCommunes(); });
        })
    </script>
@endsection

Pour simplifier le codage on crée une vue partielle pour les contrôles de formulaire :

Avec ce code :

<div class="form-group">
    <label for="{{ $name }}">{{ $title }}</label>
    <input id="{{ $name }}" type="{{ $type }}" class="form-control{{ $errors->has($name) ? ' is-invalid' : '' }}"
           name="{{ $name }}" value="{{ old($name, isset($value) ? $value : '') }}" {{ $required ? 'required' : ''}}>

    @if ($errors->has($name))
        <div class="invalid-feedback">
            {{ $errors->first($name) }}
        </div>
    @endif
</div>

Ca fait beaucoup de code mais on va détailler un peu tout ça pour les différentes étapes !

Normalement vous devriez obtenir le formulaire en cliquant sur le bouton. dans la partie supérieure :

Et dans la partie inférieure la localisation :

On avance !

C’est l’aspect pour un visiteur non connecté. Sinon on n’a plus l’alerte ni l’identité. Le reste restant inchangé.

Le téléchargement des images

Dropzone simplifie la gestion du téléchargement mais il y a quand même du travail ! Pour le paramétrage on se contente au départ de spécifier l’url dans l’action de la balise form :

<form method="post" action="{{ route('save-images') }}" enctype="multipart/form-data" class="dropzone" id="my-dropzone">

Du coup dès qu’on dépose une image elle est expédiée à cette adresse automatiquement. Une image miniature est créée en local pour afficher dans la zone avec un lien pour la suppression :

Mais évidemment pour le moment il ne se passe rien côté serveur !

Il nous faut coder la méthode store du contrôleur UploadImagesController :

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\ { Storage, File };
use Intervention\Image\Facades\Image;
use App\Models\Upload;

class UploadImagesController extends Controller
{
    private $photos_path;
    private $thumbs_path;

    public function __construct()
    {
        $this->photos_path = public_path('/images');
        $this->thumbs_path = public_path('/thumbs');
    }

    /**
     * Saving images uploaded through XHR Request.
     *
     * @param  \Illuminate\Http\Request $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request)
    {
        $photos = $request->file('file');

        if (!is_array($photos)) {
            $photos = [$photos];
        }

        if (!is_dir($this->photos_path)) {
            mkdir($this->photos_path);
        }

        if (!is_dir($this->thumbs_path)) {
            mkdir($this->thumbs_path);
        }

        for ($i = 0; $i < count($photos); $i++) {
            $photo = $photos[$i];
            $name = str_random(30);
            $save_name = $name . '.' . $photo->getClientOriginalExtension();

            Image::make($photo)
                ->resize(150, null, function ($constraints) {
                    $constraints->aspectRatio();
                })
                ->save($this->thumbs_path . '/' . $save_name);

            $photo->move($this->photos_path, $save_name);

            $upload = new Upload();
            $upload->filename = $save_name;
            $upload->original_name = basename($photo->getClientOriginalName());
            $upload->index = $request->session()->get('index');
            $upload->ad_id = 0;
            $upload->save();
        }
    }
}

Les images vont dans le dossier public/images et les miniatures dans le dossier public/thumbs. ces dossiers sont créés s’ils n’existent pas. le processus pour récupérer les images et les sauvegarder est simplifié grâce à Intervention Image. On crée un nom aléatoire de 30 caractères pour chaque image, le même nom pour l’image complète et sa miniature, puisqu’elles se trouvent dans des dossiers différents il n’y a pas de confusion possible.

On mémorise les informations dans la table uploads en se servant du modèle Upload :

  • le nom de 30 caractères
  • le nom original (pour le cas du rechargement de la page)
  • l’index au niveau de la session pour lier les images téléchargées avec la session
  • l’identifiant de l’annonce correspondante (au départ c’est 0 parce qu’elle n’existe pas encore)

Avec Dropzone le paramétrage se fait dans le Javascript au niveau des options :

Dropzone.options.myDropzone = {
    uploadMultiple: true,
    parallelUploads: 3,
    maxFilesize: 3,
    maxFiles: 3,
    dictMaxFilesExceeded : 'Vous ne pouvez charger que 3 photos',
    previewTemplate: document.querySelector('#preview').innerHTML,
    addRemoveLinks: true,
    acceptedFiles: 'image/*',
    dictInvalidFileType : 'Type de fichier interdit',
    dictRemoveFile: 'Supprimer',
    dictFileTooBig: 'L\'image fait plus de 3 Mo',
    timeout: 10000,

On voit ainsi qu’on peut envoyer en parallèle 3 images, qu’on limite aussi à 3 le nombre d’images, etc…

Le rechargement de la page

Comme je l’ai dit précédemment il faut gérer le rechargement éventuel de la page. Dans ce cas on charge le formulaire mais que faire des images déjà téléchargées ? On va mettre en place une procédure pour les récupérer sur le serveur et recréer la données pour Dropzone. C’est ce javascript qui s’en charge lors de l’initalisation de Dropzone :

$.get('{{ route('server-images') }}', data => {
    $.each(data.images, (key, value) => {
        const mockFile = {
            name: value.original,
            size: value.size,
            dataURL: '{{ url('images') }}' + '/' + value.server
        };
        myDropzone.files.push(mockFile);
        myDropzone.emit("addedfile", mockFile);
        myDropzone.createThumbnailFromUrl(mockFile,
        myDropzone.options.thumbnailWidth,
        myDropzone.options.thumbnailHeight,
        myDropzone.options.thumbnailMethod, true, (thumbnail) => {
            myDropzone.emit('thumbnail', mockFile, thumbnail);
        });
        myDropzone.emit('complete', mockFile);
    });
});

On va compléter le contrôleur UploadImageController avec cette méthode :

public function getServerImages(Request $request)
{
    if($request->session()->has('index')) {
        $imageAnswer = [];

        $index = $request->session()->get('index');
        $images = Upload::whereIndex($index)->get();

        foreach ($images as $image) {
            $imageAnswer[] = [
                'original' => $image->original_name,
                'server' => $image->filename,
                'size' => File::size($this->photos_path . '/' . $image->filename),
            ];
        }

        if(!empty($imageAnswer)) {
            return response()->json([
                'images' => $imageAnswer
            ]);
        }
    }
}

On vérifie qu’on a mémorisé quelque chose en session. Si c’est le cas on va aller récupérer toutes les images dans la table uploads. Ensuite on prépare la réponse pour le client.

Suppression d’une image

Avec Dropzone on peut aussi supprime rune image avec le lien dédié. IL est immédiatment supprimé côté client mais il faut aussi aller faire un nettoyage sur le serveur. Au niveau du Javascript ça se passe ici :

this.on("removedfile", file => {
    $.ajax({
        method: 'delete',
        url: '{{ route('destroy-images') }}',
        data: { name: file.name, _token: $('[name="_token"]').val() }
    });
});

On complète le contrôleur UploadImageController avec cette méthode :

public function destroy(Request $request)
{
    $uploaded_image = Upload::where('original_name', basename($request->name))->first();

    if (!empty($uploaded_image)) {

        $file_path = $this->photos_path . '/' . $uploaded_image->filename;
        $thumb_file = $this->thumbs_path . '/' . $uploaded_image->filename;

        if (file_exists($file_path)) {
            unlink($file_path);
        }

        if (file_exists($thumb_file)) {
            unlink($thumb_file);
        }

        $uploaded_image->delete();
    }
}

On recherche l’image dans la table uploads grâce à son nom original. Si on la trouve on supprime les deux versions de l’image sur le serveur. On finit par nettoyer la table.

Enregistrement de l’annonce

Maintenant que le problème des images est réglé voyons le reste des infrmattions du formulaire. Vous retrouverez les routines qu’on a déjà vues pour aller récupérer les départements et communes sur l’API geo.

Pour le reste on a des champs de formulaire classiques à prendre en compte à la soumission.

La validation

On va créer un Form Request pour la validation :

php artisan make:request AdStore

On ne va s’intéresser qu’aux champ de texte étant donné que pour les listes il est difficile de faire une erreur de saisie :

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class AdStore extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'title' => ['required', 'string', 'max:100'],
            'texte' => ['required', 'string', 'max:1000'],
            'pseudo' => ['sometimes', 'required', 'string', 'max:20'],
            'email' => ['sometimes', 'required', 'string', 'email', 'max:255'],
        ];
    }
}

On a un sometimes pour les champs pseudo et email qui ne sont pas présents pour un utilisateur connecté.

Le contrôleur

Dans le contrôleur AdController on complète la méthode store :

use App\Http\Requests\AdStore;
use Carbon\Carbon;
use App\Models\ { Category, Region, Ad, Upload };

...

public function store(AdStore $request)
{
    $commune = json_decode(file_get_contents('https://geo.api.gouv.fr/communes/' . $request->commune), true);

    $ad = $this->adRepository->create([
        'title' => $request->title,
        'texte' => $request->texte,
        'category_id' => $request->category,
        'region_id' => $request->region,
        'departement' => $request->departement,
        'commune' => $request->commune,
        'commune_name' => $commune['nom'],
        'commune_postal' => $commune['codesPostaux'][0],
        'user_id' => auth()->check() ? auth()->id() : 0,
        'pseudo' => auth()->check() ? auth()->user()->name :$request->pseudo,
        'email' => auth()->check() ? auth()->user()->email : $request->email,
        'limit' => Carbon::now()->addWeeks($request->limit),
    ]);

    if($request->session()->has('index')) {
        $index = $request->session()->get('index');
        Upload::whereIndex($index)->update(['ad_id' => $ad->id, 'index' => 0]);
    }

    return view('adconfirm');
}

Il faut aller chercher les renseignements de la commune avec l’API parce qu’on ne reçoit que le code.

On ajoute la méthode create dans le repository (AdRepository) :

public function create($data)
{
    return Ad::create($data);
}

On peut alors l’utiliser dans le contrôleur pour enregistrer toutes les données.

En ce qui concerne les photos on vérifie qu’on a quelque chose en session et si c’est le cas pour chaque photo mémorisée on actualise le champ ad_id dans la table uploads pour lier les photos à l’annonce.

On retroune à la fin une vue adconfirm :

Avec ce simple code :

@extends('layouts.app')

@section('content')

<div class="container">
    <div class="jumbotron">
        <div class="row">
            <div class="col-md-4">
                <img src="{{ asset('img/speaker.png') }}" alt="">
            </div>
            <div class="col-md-8">
                <h1 class="display-4">Confirmation d'annonce</h1>
                <p class="lead">Votre annonce a bien été prise en compte, elle sera publiée après modération.</p>
            </div>
        </div>
    </div>
</div>

@endsection

Si vous avez bien codé vous devriez obtenir ça (l’image est dans le dossier à télécharger) :

Conclusion

On a désormais achevé le traitement côté client avec la création des annonces. Je ne suis pas entré dans le détail de tout le code pour ne pas alourdir l’article. Dans le prochain article on commencera la mise en place de l’administration.




Un site d’annonces – annonce et message

Dans cet article on va voir l’affichage des détails d’une annonce (titre, texte, photos…). On va aussi prévoir qu’un visiteur puisse envoyer un message à l’émetteur de l’annonce pour se mettre en relation avec lui.

Pour vous simplifier la vie vous pouvez télécharger le dossier complet pour le code de cet article.

La liste des annonces

On en est restés avec la possibilité d’obtenir la liste des annonces pour une localisation (région, département, commune) et une catégorie :

Mais je ne suis pas très satisfait de l’aspect actuel avce le lien en bleu et le soulignement au survol, j’aimerais quelque chose de plus esthétique. On va ajouter quelques règles dans resources\sass\app.scss :

a.blockAd {
    text-decoration: none;
    color: inherit;
    &:hover h4 {
        color: #e47517;
    }
    &:hover .card {
        box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
    }
}

On lance une compilation…

Maintenant le texte de l’annonce est noir :

Et au survol le titre devient orange et on ajoute un peu d’ombre autour de la carte :

Voilà qui est mieux !

On a déjà mis en place la route pour l’affichage d’une annonce :

Route::prefix('annonces')->group(function () {
    Route::get('voir/{ad}', 'AdController@show')->name('annonces.show');

Quand je survole une annonce je vois le lien de la forme annonces/voir/{id}. On doit diriger la requête vers la méthode show du contrôleur qu’on n’a pas encore codée.

Contrôleur, repository et autorisation

Dans le contrôleur AdController on va prévoir ce code pour la méthode show :

use App\Models\ { Category, Region, Ad };

...

public function show(Ad $ad)
{
    $this->authorize('show', $ad);

    $photos = $this->adRepository->getPhotos($ad);

    return view('ad', compact('ad', 'photos'));
}

Avec la liaison de données au niveau du paramètre de la route on récupère directement une annonce dans la variable $ad.

Une autorisation

On sait qu’on a des annonces valides, qui ont été modérées par un administrateur et des non valides. il ne faudrait pas qu’un petit malin change l’identifiant dans l’url pour aller lire une annonce pas encore valide. On va donc mettre en place une autorisation pour accéder à une annonce : si elle est valide pas de souci, si elle ne l’est pas on laisse l’accès uniquement à celui qui l’a créée et qui a un compte et aussi aux administrateurs.

On utilise Artisan pour créer cette autorisation (policy) :

php artisan make:policy AdPolicy --model=Ad

On a un squelette de code, on va le compléter ainsi :

<?php

namespace App\Policies;

use App\Models\ { User, Ad };
use Illuminate\Auth\Access\HandlesAuthorization;

class AdPolicy
{
    use HandlesAuthorization;

    /**
     * Grant all abilities to administrator.
     *
     * @param  \App\Models\User  $user
     * @return bool
     */
    public function before(User $user)
    {
        if($user->admin) {
            return true;
        }
    }

    /**
     * Determine whether the user can view the ad.
     *
     * @param  \App\Models\User  $user
     * @param  \App\Models\Ad  $ad
     * @return mixed
     */
    public function show(?User $user, Ad $ad)
    {
        if($user && $user->id == $ad->user_id) {
            return true;
        }
        return $ad->active;
    }
}

Avec la méthode before on prévoit d’autoriser les administrateur de façon systématique. Avec la méthode show on autorise le visiteur, connecté ou pas, à accéder si l’annonce est valide ou si le visiteur connecté est l’auteur de l’annonce.

On enregistre cette autorisation dans AuthServiceProvider :

...
use App\Policies\AdPolicy;
use App\Models\Ad;

class AuthServiceProvider extends ServiceProvider
{
    protected $policies = [
        Ad::class => AdPolicy::class
    ];

Maintenant on peut l’utiliser dans le contrôleur :

$this->authorize('show', $ad);

Le repository

On peut avoir des photos associées à l’annonce. Dans le repository (AdRepository) on va prévoir une méthode pour aller récupérer ces photos :

public function getPhotos($ad)
{
    return $ad->photos()->get();
}

On se contente d’utiliser la relation qu’on a déjà mis en place.

On peut maintenant appeler cette méthode à partir du contrôleur :

$photos = $this->adRepository->getPhotos($ad);

Et pour finir on appelle la vue en transmettant les données :

return view('ad', compact('ad', 'photos'));

La vue pour afficher une annonce

On crée la vue :

Avec ce code :

@extends('layouts.app')

@section('content')

<div class="container">

    <div class="card bg-light">
        <h5 class="card-header">{{ $ad->title }}</h5>
        @if($photos->isNotEmpty())
            @if($photos->count() > 1)
                <div id="ctrl" class="carousel slide" data-ride="carousel">
                    <ol class="carousel-indicators">
                         @foreach ($photos as $photo)
                            <li data-target="#ctrl" data-slide-to="{{ $loop->index }}" @if($loop->first) class="active" @endif></li>
                        @endforeach
                    </ol>
                    <div class="carousel-inner">
                        @foreach ($photos as $photo)
                            <div class="carousel-item @if($loop->first) active @endif">
                                <img class="d-block w-100" src="{{ asset('images/' . $photo->filename) }}" alt="First slide">
                            </div>
                        @endforeach
                    </div>
                    <a class="carousel-control-prev" href="#ctrl" role="button" data-slide="prev">
                        <span class="carousel-control-prev-icon" aria-hidden="true"></span>
                        <span class="sr-only">Précédent</span>
                    </a>
                    <a class="carousel-control-next" href="#ctrl" role="button" data-slide="next">
                        <span class="carousel-control-next-icon" aria-hidden="true"></span>
                        <span class="sr-only">Suivant</span>
                    </a>
                </div>
            @else
                <img class="card-img-top" src="{{ asset('images/' . $ad->photos->first()->filename) }}">
            @endif
        @endif
        <div class="card-body">
            <hr>
            <p><u>Description :</u></p>
            <p class="card-text">{{ $ad->texte }}</p>
            <hr>
            <p class="card-text"><u>Catégorie</u> : {{ $ad->category->name }}</p>
            <p class="card-text">
                <u>Ville</u> : {{ $ad->commune_name . ' (' . $ad->commune_postal . ')'}}<br>
                <u>Publication</u> : {{ $ad->created_at->calendar() }}
            </p>
            <hr>
            <p class="card-text"><u>Pseudo</u> : {{ $ad->pseudo }}</p>
        </div>
    </div>

</div>

@endsection

On affiche en premier le titre de l’annonce :

<h5 class="card-header">{{ $ad->title }}</h5>

Ensuite s’il y a des photos :

@if($photos->isNotEmpty())

On a deux cas :plusieurs photos alors on prévoir un caroussel :

@if($photos->count() > 1)
    <div id="ctrl" class="carousel slide" data-ride="carousel">
        ...
    </div>
@else

Ou une seule photo, alors on l’affiche de façon classique :

@else
    <img class="card-img-top" src="{{ asset('images/' . $ad->photos->first()->filename) }}">
@endif

On renseigne ensuite dans le corps de la carte :

  • la description
  • la catégorie
  • la ville
  • la date de publication
  • le pseudo du rédacteur

On a par exemple cet aspect :

Les messages

On affiche les annonces mais on n’a rien pour permettre au visiteur de contacter l’auteur de l’annonce. On va donc ajouter un bouton pour l’envoi d’un message. On va prévoir deux cas :

  • le visiteur est un utilisateur authentifié, dans ce cas on expédie directement le message
  • le visiteur est un inconnu, dans ce cas on mémorise le message qui sera modéré par un administrateur

Route et contrôleur

On va prévoir une route :

Route::middleware('ajax')->group(function () {
    Route::post('message', 'UserController@message')->name('message');
});

On crée un groupe ajax parce qu’on aura d’autre routes à mettre là. On voit qu’on va utiliser un nouveau contrôleur UserController. On commence par le créer :

php artisan make:controller UserController

On prévoit ce code :

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

use App\Notifications\AdMessage;
use App\Http\Requests\MessageAd;
use App\Repositories\ { AdRepository, MessageRepository };

class UserController extends Controller
{
    protected $adRepository;
    protected $messagerepository;

    public function __construct(AdRepository $adRepository, Messagerepository $messagerepository)
    {
        $this->adRepository = $adRepository;
        $this->messagerepository = $messagerepository;
    }

    public function message(MessageAd $request)
    {
        $ad = $this->adRepository->getById($request->id);

        if(auth()->check()) {
            $ad->notify(new AdMessage($ad, $request->message, auth()->user()->email));
            return response()->json(['info' => 'Votre message va être rapidement transmis.']);
        }

        $this->messagerepository->create([
            'texte' => $request->message,
            'email' => $request->email,
            'ad_id' => $ad->id,
        ]);

        return response()->json(['info' => 'Votre message a été mémorisé et sera transmis après modération.']);
    }
}

Pour la validation j’ai prévu une form request MessageAd.

La form request

On la crée avec Artisan :

php artisan make:request MessageAd

Avec ce code :

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class MessageAd extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'message' => ['required', 'string', 'max:500'],
            'email' => ['sometimes', 'required', 'string', 'email', 'max:255'],
        ];
    }
}

On a deux paramètres :

  • message : c’est le texte du message (on limite la longueur à 500 caractères)
  • email : c’est l’email de celui qui veut envoyer le message

Il y a sometimes pour la validation de l’email parce que si c’est un utilisateur connecté qui laisse un message on ne va pas lui demander d’entrer son email parce qu’on le connait déjà.

Le repository AdRepository

Dans adRepository on ajoute une méthode pour aller chercher une annonce à partir de son identifiant :

public function getById($id)
{
    return Ad::findOrFail($id);
}

On peut ainsi l’utiliser dans le contrôleur :

$ad = $this->adRepository->getById($request->id);

Il ne faudra pas oublier d’envoyer cet identifiant dans le formulaire, dans un champ caché.

La notification

Pour l’envoi du message on va utiliser le système de notification de Laravel. En général il est associé au modèle User mais dans notre cas on l’associe au modèle Ad. d’ailleurs lorsqu’on a créé ce modèle on a déjà ajouté le trait :

class Ad extends Model
{
    use Notifiable;

On crée la notification :

php artisan make:notification AdMessage

On y colle ce code :

<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;

class AdMessage extends Notification
{
    use Queueable;

    protected $ad;
    protected $message;
    protected $email;

    public function __construct($ad, $message, $email)
    {
        $this->ad = $ad;
        $this->message = $message;
        $this->email = $email;
    }

    public function via($notifiable)
    {
        return ['mail'];
    }

    public function toMail($notifiable)
    {
        return (new MailMessage)
                    ->line('Vous avez reçu un message concernant une annonce que vous avez déposée :')
                    ->line('--------------------------------------')
                    ->line($this->message)
                    ->line('--------------------------------------')
                    ->action('Voir votre annonce', route('annonces.show', $this->ad->id))
                    ->line("L'email de l'expéditeur est : " . $this->email)
                    ->line("Merci d'utiliser notre site pour vos annonces !");
    }

    public function toArray($notifiable)
    {
        return [
            //
        ];
    }
}

Maintenant dans le contrôleur on peut envoyer la notification pour un utilisateur connecté :

if(auth()->check()) {
    $ad->notify(new AdMessage($ad, $request->message, auth()->user()->email));
    return response()->json(['info' => 'Votre message va être rapidement transmis.']);
}

Le repository MessageRepository

Pour un simple visiteur on doit mémoriser le message dans la table messages en attente de validation par un administrateur. pour la gestion des données on crée un repository :

Et ce code :

<?php

namespace App\Repositories;

use App\Models\Message;

class MessageRepository
{
    public function create($data)
    {
        return Message::create($data);
    }
}

On peut alors l’utiliser dans le contrôleur :

$this->messagerepository->create([
    'texte' => $request->message,
    'email' => $request->email,
    'ad_id' => $ad->id,
]);

On complète la vue ad

Maintenant que notre intendance est en place on va compléter la vue avec un bouton qui va ouvrir une fenêtre modale avec le formulaire pour le message. Je mets le code complet de la vue ad :

@extends('layouts.app')

@section('content')

<div class="container">

    <div id="massageOk" class="alert alert-success" role="alert" style="display: none">
        Votre message a été pris en compte et sera envoyé rapidement
    </div>

    @include('partials.message', ['url' => route('message')])

    <div class="card bg-light">
        <h5 class="card-header">{{ $ad->title }}</h5>
        @if($photos->isNotEmpty())
            @if($photos->count() > 1)
                <div id="ctrl" class="carousel slide" data-ride="carousel">
                    <ol class="carousel-indicators">
                         @foreach ($photos as $photo)
                            <li data-target="#ctrl" data-slide-to="{{ $loop->index }}" @if($loop->first) class="active" @endif></li>
                        @endforeach
                    </ol>
                    <div class="carousel-inner">
                        @foreach ($photos as $photo)
                            <div class="carousel-item @if($loop->first) active @endif">
                                <img class="d-block w-100" src="{{ asset('images/' . $photo->filename) }}" alt="First slide">
                            </div>
                        @endforeach
                    </div>
                    <a class="carousel-control-prev" href="#ctrl" role="button" data-slide="prev">
                        <span class="carousel-control-prev-icon" aria-hidden="true"></span>
                        <span class="sr-only">Précédent</span>
                    </a>
                    <a class="carousel-control-next" href="#ctrl" role="button" data-slide="next">
                        <span class="carousel-control-next-icon" aria-hidden="true"></span>
                        <span class="sr-only">Suivant</span>
                    </a>
                </div>
            @else
                <img class="card-img-top" src="{{ asset('images/' . $ad->photos->first()->filename) }}" alt="Card image cap">
            @endif
        @endif
        <div class="card-body">
            <hr>
            <p><u>Description :</u></p>
            <p class="card-text">{{ $ad->texte }}</p>
            <hr>
            <p class="card-text"><u>Catégorie</u> : {{ $ad->category->name }}</p>
            <p class="card-text">
                <u>Ville</u> : {{ $ad->commune_name . ' (' . $ad->commune_postal . ')'}}<br>
                <u>Publication</u> : {{ $ad->created_at->calendar() }}
            </p>
            <hr>
            <p class="card-text"><u>Pseudo</u> : {{ $ad->pseudo }}</p>
            <button id="openModal" type="button" class="btn btn-primary">Envoyer un message</button>
        </div>
    </div>

</div>

@endsection

@section('script')
    <script>

        $(() => {

            const toggleButtons = () => {
                $('#icon').toggle();
                $('#buttons').toggle();
            }

            $('#openModal').click(() => {
                $('#messageModal').modal();
            });

            $('#messageForm').submit((e) => {
                let that = e.currentTarget;
                e.preventDefault();
                $('#message').removeClass('is-invalid');
                $('.invalid-feedback').html('');
                toggleButtons();
                $.ajax({
                    method: $(that).attr('method'),
                    url: $(that).attr('action'),
                    data: $(that).serialize()
                })
                .done((data) => {
                    toggleButtons();
                    $('#messageModal').modal('hide');
                    $('#massageOk').text(data.info).show();
                })
                .fail((data) => {
                    toggleButtons();
                    $.each(data.responseJSON.errors, function (i, error) {
                        $(document)
                            .find('[name="' + i + '"]')
                            .addClass('is-invalid')
                            .next()
                            .append(error[0]);
                    });
                });
            });

        })

    </script>
@endsection

On voit que la page modale est incluse dans cette vue à partir d’une vue partielle (on s’en reservira plus tard) :

La vue partielle

Voici le code de la vue partielle message :

<div class="modal fade" id="messageModal" tabindex="-1" role="dialog" aria-labelledby="message" aria-hidden="true">
    <div class="modal-dialog modal-dialog-centered modal-lg" role="document">
        <div class="modal-content">
            <form id="messageForm" method="POST" action="{{ $url }}">
                @csrf
                <div class="modal-body">

                    @guest
                        <div class="alert alert-warning alert-dismissible fade show" role="alert">
                            Vous n'êtes pas connecté. Votre message sera modéré avant expédition.
                            <button type="button" class="close" data-dismiss="alert" aria-label="Close">
                                <span aria-hidden="true">&times;</span>
                            </button>
                        </div>
                    @endguest

                    <input id="id" name="id" type="hidden" value="{{ isset($ad) ? $ad->id : '' }}">

                    <div class="form-group">
                        <label for="texte">Entrez ici votre message</label>
                        <textarea class="form-control" id="message" name="message" rows="3" required>{{ old('texte', isset($value) ? $value : '') }}</textarea>
                        <div id="messageError" class="invalid-feedback"></div>
                    </div>

                    @guest
                        <div class="form-group">
                            <label for="email">Votre email pour vous contacter</label>
                            <input type="email" class="form-control" name=email id="email" required>
                            <div id="emailError" class="invalid-feedback"></div>
                        </div>
                    @endguest

                </div>
                <div class="modal-footer">
                    <div id="buttons">
                        <button type="button" class="btn btn-secondary" data-dismiss="modal">Annuler</button>
                        <button type="submit" class="btn btn-primary">Envoyer</button>
                    </div>
                    <i id="icon" class="fas fa-spinner fa-pulse fa-2x" style="display: none"></i>
                </div>
            </form>
        </div>
    </div>
</div>

Maintenant on a un bouton en bas de la page :

Quand on clique ça ouvre la page modale :

Pour un utilisateur connecté au aura l’alerte en moins et pas de champ email :

La gestion de la soumission se fait en Javascript avec JQuery.

Comme on utilise une icône de Font Awesome on va ajouter le chargement de cette librairie dans notre layout (resources\layouts\app) :

<!-- Styles -->
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css">
<link href="{{ asset('css/app.css') }}" rel="stylesheet">

L’icône animée remplace les deux boutons le temps que se fasse la communication avec le serveur.

Vous pouvez vérifier que la validation se passe bien :

Pour un simple visiteur il y a affichage d’un message au retour du serveur :

Et vous pourrez vérifier que le message se mémorise bien dans la base.

Sinon le message part en email :

 

Là encore ça mérite une francisation. On va publier le template :

php artisan vendor:publish --tag=laravel-notifications

En adaptant un peu les textes :

@component('mail::message')
{{-- Greeting --}}
@if (! empty($greeting))
# {{ $greeting }}
@else
@if ($level === 'error')
# @lang('Whoops!')
@else
# Bonjour !
@endif
@endif

{{-- Intro Lines --}}
@foreach ($introLines as $line)
{{ $line }}

@endforeach

{{-- Action Button --}}
@isset($actionText)
<?php
    switch ($level) {
        case 'success':
        case 'error':
            $color = $level;
            break;
        default:
            $color = 'primary';
    }
?>
@component('mail::button', ['url' => $actionUrl, 'color' => $color])
{{ $actionText }}
@endcomponent
@endisset

{{-- Outro Lines --}}
@foreach ($outroLines as $line)
{{ $line }}

@endforeach

{{-- Salutation --}}
@if (! empty($salutation))
{{ $salutation }}
@else
Cordialement,<br>{{ config('app.name') }}
@endif

{{-- Subcopy --}}
@isset($actionText)
@slot('subcopy')
@lang(
    "If you’re having trouble clicking the \":actionText\" button, copy and paste the URL below\n".
    'into your web browser: [:actionURL](:actionURL)',
    [
        'actionText' => $actionText,
        'actionURL' => $actionUrl,
    ]
)
@endslot
@endisset
@endcomponent

Maintenant c’est mieux :

Il ne reste plus qu’un détail, dans le titre de l’email on a « Laravel », on va mettre a jour le fichier .env :

APP_NAME=Annonces

Conclusion

On a maintenant toutes les fonctionnalités côté client pour la recherche des annonces, leur affichage et l’envoi de message au rédacteur. On aura encore du travail côté client pour proposer une interface de gestion du profil. Dans le prochain article on mettra en place l’intendance pour la création d’une annonce.




Un site d’annonces – les annonces

Dans ce nouvel article on va s’intéresser à l’affichage des annonces. Sur la page d’accueil qu’on a créée dans la précédente étape on a une sélection de la région. On va ajouter la sélection optionnelle du département et de la commune. Selon ces choix on va afficher une liste paginée des annonces correspondantes. On pourra ensuite cliquer sur l’une d’elle pour afficher tous ses détails.

Pour vous simplifier la vie vous pouvez télécharger le dossier complet pour le code de cet article.

Routes, contrôleur et middleware

On va commencer par définir les routes dont on va avoir besoin pour les annonces ainsi que le contrôleur. A priori notre contrôleur va avoir la plupart des méthodes d’un contrôleur de ressource. Alors on le crée :

php artisan make:controller AdController --resource

Pour les routes on va les définir ainsi :

Route::resource('annonces', 'AdController')
    ->parameters([
        'annonces' => 'ad'
    ])->except([
        'index', 'show', 'destroy'
]);

Route::prefix('annonces')->group(function () {
    Route::get('voir/{ad}', 'AdController@show')->name('annonces.show');
    Route::get('{region?}/{departement?}/{commune?}', 'AdController@index')->name('annonces.index');
    Route::post('recherche', 'AdController@search')->name('annonces.search')->middleware('ajax');
});

On commence par définir partiellement les routes de la ressource en excluant (except) index, show et destroy qui seront définies ailleurs.

On redéfinit le paramètre pour avoir dans l’url annonce, plutôt que ad, ce qui est plus explicite.

On a ensuite un groupe de 3 routes :

  • pour voir une annonce (show) mais en ayant le mot voir dans l’url,
  • l’url de recherche avec 3 paramètres optionnels (region, departement et commune) étant entendu qu’il y a une hiérarchie et que la région par exemple ne peut pas être optionnelle si on choisit un département et ainsi de suite, sinon ça ne marcherait pas,
  • enfin une route de recherche qui sera en Ajax (on voit qu’il y a un middleware qu’on va devoir créer).

Vous avez compris que l’actualisation de la recherche va se faire en Ajax.

Voilà un petit point :

Le middleware Ajax

Puisqu’il nous faut un middleware pour Ajax créons le :

php artisan make:middleware Ajax

Avec ce code :

<?php

namespace App\Http\Middleware;

use Closure;

class Ajax
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request $request
     * @param  \Closure $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        if ($request->ajax()) {
            return $next($request);
        }

        abort(404);
    }
}

Et il ne faut pas oublier de le déclarer dans app\Http\Kernel :

protected $routeMiddleware = [
    ...
    'ajax' => \App\Http\Middleware\Ajax::class,
];

La page d’accueil

On avait pas mis les adresses dans les liens de la page d’accueil (home) pour les régions parce qu’on avait pas encore prévu les routes. On va pouvoir maintenant les compléter :

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-12 col-md-3 col-lg-3">
            <a class="btn btn-primary" href="" role="button">Déposer une annonce</a>
        </div>
        <div class="col-12 col-md-9 col-lg-9">
            <svg xmlns:svg="http://www.w3.org/2000/svg" viewBox="0 0 900 900">
                <a href="{{ route('annonces.index', 'occitanie') }}">
                    <path class="base" data-toggle="tooltip" title="Occitanie"
                        d="m 428.3,774.7 68.28824,1.41765 L 494.4,720.6 l 52.5,-38.8 14.8,7 25.1,-26 0.9,-38.1 -37.5,-8.3 -9.7,-29.4 -34.6,-24.3 -15.42353,24.04118 -15.61765,-21.68824 -21.22941,26.62353 -22.55294,-3.91765 -2.51176,-23.22941 -34.12942,-3.54706 L 351.5,638.6 293.45294,646.81765 302.82353,749.64706 356.4,744.2 Z"/>
                </a>
                <a href="{{ route('annonces.index', 'pays_de_la_loire') }}">
                    <path class="base" data-toggle="tooltip" title="Pays de la Loire"
                        d="M 268.2,432.3 229.2353,441.52941 182.9,401.6 162.2,343.4 231.37059,312.44118 247.29412,255.67647 356.5,281.5 l -20.8,46.1 -30.75882,38.22353 -56.11765,14.82353 z""/>
                </a>
                <a href="{{ route('annonces.index', 'bretagne') }}">
                    <path class="base" data-toggle="tooltip" title="Bretagne"
                        d="M 226.05882,309.17647 157.23529,338.29411 41.82353,307.05882 20.647059,288 48.176471,281.64706 16.941177,259.94117 25.8,247 123.35294,218.64706 143.47059,248.29411 207.5,243.2 242,252.5 Z""/>
                </a>
                <a href="{{ route('annonces.index', 'centre') }}">
                    <path class="base" data-toggle="tooltip" title="Centre-Val de Loire"
                        d="m 313.41177,364.7647 c 9,7.41177 71.31764,68.52942 71.31764,68.52942 L 441.04118,427.20588 490.2,398.1 487.95294,290.12941 388.1513,230.74351 362.62353,241.30588 362.1,283.3 342,329.82353 Z"/>
                </a>
                <a href="{{ route('annonces.index', 'normandie') }}">
                    <path class="base" data-toggle="tooltip" title="Normandie"
                        d="m 323.71176,174.21765 -88.65294,2.60588 -9.52941,-29.11765 -36,-1.58824 25.19191,92.05393 141.17868,35.3929 0.7,-36.86447 33.52419,-16.52616 22.53911,-31.1701 2.80632,-42.7111 L 392.6,117.1 312.35294,151.41176 Z"/>
                </a>
                <a href="{{ route('annonces.index', 'ile_de_france') }}">
                    <path class="base" data-toggle="tooltip" title="Île-de-France"
                        d="M 397.58824,227.11764 415,194 l 79,6.6 20.1,26 -1.1,37.7 -29.3,16.7 z"/>
                </a>
                <a href="{{ route('annonces.index', 'hauts_de_france') }}">
                    <path class="base" data-toggle="tooltip" title="Hauts-de-France"
                        d="m 459.5,28.1 -55.3,18.4 -3.4,39.5 -3.53336,26.02312 23.75689,29.84747 -3.0264,46.31423 76.47889,6.53412 20.30295,23.76216 46.87398,-86.33992 L 558.3,99 Z"/>
                </a>
                <a href="{{ route('annonces.index', 'grand_est') }}">
                    <path class="base" data-toggle="tooltip" title="Grand Est"
                        d="M 563.57853,103.93079 635.69174,156.40927 800,212.3 774.52942,246.70588 757.9,336.4 l -19.6,7.6 -12.21765,-32.78235 -64.04787,-12.25792 -36.27715,30.39069 -38.03513,-33.68543 -43.84789,1.1548 -25.04824,-32.27372 0.18819,-41.30026 48.92475,-91.74037 z/"">
                </a>
                <a href="{{ route('annonces.index', 'bourgogne') }}">
                    <path class="base" data-toggle="tooltip" title="Bourgogne-Franche-Comté"
                        d="m 665.06471,305.92941 56.52353,11.18823 10.88823,27.94118 -71.77058,99.11765 L 635.1,442.5 l -4.6043,-15.10589 -25.49168,-3.77814 -15.22208,26.95233 -46.4355,-3.2994 7.80392,-22.04274 -55.53596,-28.13453 -0.1772,-114.99224 20.02314,-11.68867 26.14333,32.71916 47.7414,0.78345 38.12551,35.23961 z"/>
                </a>
                <a href="{{ route('annonces.index', 'auvergne') }}">
                    <path class="base" data-toggle="tooltip" title="Auvergne-Rhône-Alpes"
                        d="M 535.45882,450.42353 593.6,455.2 608.82353,429.88235 625.7,431.6 l 5.35883,17.87059 36,1.05882 36,-21.17647 30.70588,101.11765 L 632.9,608.1 l 12.8,20.3 -13.5,2.3 -45.6,-16.1 -30.18823,-2.6 -11.11765,-29.64706 -39.13122,-29.42816 -16.90577,21.26103 -15.41509,-17.33867 -22.25617,29.48934 -17.33293,-4.9306 1.98236,-19.69999 30.04875,-93.6762 -19.98993,-33.38263 45.80543,-30.84615 49.88761,25.00432 z"/>
                </a>
                <a href="{{ route('annonces.index', 'provence') }}">
                    <path class="base" data-toggle="tooltip" title="Provence-Alpes-Côte d'Azur"
                        d="m 594,625.23529 c 9,4.76471 41.29412,12.70589 41.29412,12.70589 l 18,-3.17648 -12.08824,-25.51764 68.20589,-54.42353 24.35294,26.47059 L 726.4,621.1 769.76471,636.88235 696.8,716.5 676.3,726.7 567,690.35294 592.94118,664.41176 Z"/>
                </a>
                <a href="{{ route('annonces.index', 'corse') }}">
                    <path class="base" data-toggle="tooltip" title="Corse"
                        d="m 773.65294,695.94118 5.7,72.5 -18.2,63.7 -19.2,-6.7 -24.6,-65.20589 12.17648,-29.11764 L 765,715.23529 l 1.05883,-20.64706 z"/>
                </a>
                <a href="{{ route('annonces.index', 'aquitaine') }}">
                    <path class="base" data-toggle="tooltip" title="Nouvelle-Aquitaine"
                        d="m 224.35882,446.83529 c 9,4.76471 51.35295,-11.64705 51.35295,-11.64705 l -15.35294,-49.76472 48.79411,-13.87058 75.36142,69.83442 53.72682,-6.19912 20.62104,34.50899 -27.50339,87.78513 -40.71765,-4.66471 L 349.3,630.54117 285.86471,643.45294 293.62353,745.47059 205.3,694.6 214.82942,628.95294 235.1874,506.12438 Z"/>
                </a>
            </svg>
        </div>
    </div>
</div>
@endsection

@section('script')

<script>
    $(function(){
        $('[data-toggle="tooltip"]').tooltip();
    });
</script>
@endsection

Maintenant pour chaque région on a un lien de la forme annonces/{slug de la region} et on vise la méthode index de notre contrôleur. On va créer maintenant cette méthode, ou plutôt la compléter parce qu’elle a été créée avec le contrôleur.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\ { Category, Region };

class AdController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  String  $regionSlug
     * @param  Integer  $departementCode
     * @param  Integer  $communeCode
     * @return \Illuminate\Http\Response
     */
    public function index(
        Request $request,
        $regionSlug = null,
        $departementCode = null,
        $communeCode = null)
    {
        $categories = Category::select('name', 'id')->oldest('name')->get();
        $regions = Region::select('id','code','name','slug')->oldest('name')->get();
        $region = $regionSlug ? Region::whereSlug($regionSlug)->firstOrFail() : null;
        $page = $request->query('page', 0);

        return view('adsvue', compact('categories', 'regions', 'region', 'departementCode', 'communeCode', 'page'));
    }

On reçoit les 3 paramètres optionnels dans la méthode.

On va avoir besoin de toutes les catégories dans la vue alors on les récupère dans l’ordre alphabétique (oldest) :

$categories = Category::select('name', 'id')->oldest('name')->get();

Il nous faut aussi la liste complète des régions, la aussi dans l’ordre alphabétique :

$regions = Region::select('id','code','name','slug')->oldest('name')->get();

Ensuite si un slug est présent pour la région (ça sera le cas quand on va cliquer sur une région de la carte) on la récupère, sinon on garde le null :

$region = $regionSlug ? Region::whereSlug($regionSlug)->firstOrFail() : null;

Ensuite on regarde s’il y a une pagination et on renvoie le numéro de la page :

$page = $request->query('page', 0);

En effet comme on va fonctionner en Ajax il va falloir tenir à jour l’url avec le numéro de page pour le cas ou l’utilisateur rechargerait cette page (il ont parfois de ces idées !).

Ensuite on envoie tout ça dans une vue :

return view('adsvue', compact('categories', 'regions', 'region', 'departementCode', 'communeCode', 'page'));

Evidemment cette vue n’existant pas encore pour le moment on obtient une jolie erreur :

Un composant

Pour la vue adsvue on va créer un composant Vue.js qu’on va appeler AdComponent. Mais pour commencer on va prévoir l’intendance de Vue.js en créant un fichier Javascript pour l’initialisation :

Avec ce simple code :

window.Vue = require('vue');

Vue.component('ad', require('./components/AdComponent.vue').default);

const app = new Vue({
    el: '#app'
});

On charge Vue.js, le composant qu’on va bientôt créer et on crée l’objet principal pour l’application.

On va le déclarer dans webpack.mix.js :

mix.js('resources/js/vue.js', 'public/js')

Si vous compiler maintenant évidemment on va vous dire que le composant n’existe pas !

On crée le fichier du composant :

En voici le code :

<template>
<div class="container">
    <div class="card bg-light">
        <h5 class="card-header">Votre recherche</h5>
        <div class="card-body">
            <form id="formAd" method="POST" :action="url">

                <div class="form-group">
                    <label for="category">Catégorie</label>
                    <select class="custom-select" name="category" id="category" @change="onCategoryChange()" v-model="categorySelected">
                        <option value="0">Toutes</option>
                        <option v-for="category in categories" :key="category.id" :value="category.id">
                            {{ category.name }}
                        </option>
                    </select>
                </div>

                <div class="form-group">
                    <label for="region">Région</label>
                    <select class="custom-select" name="region" id="region" @change="onRegionChange()" v-model="regionSelected">
                        <option data-code="0" value="0">Toute la France</option>
                        <option v-for="region in regions" :key="region.id" :value="region.id" :data-code="region.code">
                            {{ region.name }}
                        </option>
                    </select>
                </div>

                <div v-if="regionSelected != 0" class="form-group">
                    <label for="departement">Département</label>
                    <select class="custom-select" name="departement" id="departement" @change="onDepartementChange" v-model="departementSelected">
                        <option value="0">Tous</option>
                        <option v-for="departement in departements" :key="departement.code" :value="departement.code">
                            {{ departement.nom }}
                        </option>
                    </select>
                </div>

                <div v-if="departementSelected != 0" class="form-group" >
                    <label for="commune">Commune</label>
                    <select class="custom-select" name="commune" id="commune" @change="onCommuneChange" v-model="communeSelected">
                        <option value="0">Toutes</option>
                        <option v-for="commune in communes" :key="commune.code" :value="commune.code">
                            {{ commune.nom }}
                        </option>
                    </select>
                </div>
            </form>
        </div>
    </div>

    <br>

    <span v-html="ads"></span>

</div>
</template>

<script>

    export default {
        props: [
            'url',
            'categories',
            'regions'
        ],
        data () {
            return {
                categorySelected: 0,
                regionSelected: 0,
                regionIndex: 0,
                regionSlug: '',
                departements: [],
                departementSelected: 0,
                communes: [],
                communeSelected: 0,
                ads: ''
            }
        },
        methods: {
            onCategoryChange() {
                this.submit();
            },
            onRegionChange() {
                const index = event.target.selectedIndex;
                if (index) {
                    this.regionSlug = this.regions[index - 1]['slug'];
                    let code = event.target.options[index].attributes['data-code'].value;
                    this.fillDepartements(code);
                } else {
                    this.regionSelected = 0;
                }
                this.submit();
            },
            fillDepartements(code) {
                if(code) {
                    let that = this;
                    $.get('https://geo.api.gouv.fr/regions/' + code + '/departements', data => {
                        that.departements = data;
                    });
                }
                this.departementSelected = 0;
            },
            onDepartementChange() {
                const index = event.target.selectedIndex;
                if (index) {
                    this.fillCommunes(event.target.value);
                } else {
                    this.departementSelected = 0;
                }
                this.submit();
            },
            onCommuneChange() {
                this.communeId = event.target.selectedIndex;
                this.submit();
            },
            fillCommunes(code) {
                if(code) {
                    let that = this;
                    $.get('https://geo.api.gouv.fr/departements/' + code + '/communes', data  => {
                        that.communes = data;
                    });
                }
                this.communeSelected = 0;
            },
            submit(e, comp = '') {
                $.ajax({
                    method: 'post',
                    url: this.url + comp,
                    data: {
                        'category': this.categorySelected,
                        'region': this.regionSelected,
                        'departement': this.departementSelected,
                        'commune': this.communeSelected,
                        '_token': $('meta[name="csrf-token"]').attr('content')
                    }
                })
                .done(data => {
                    this.ads = data;
                    let ref = '/annonces';
                    if(this.regionSelected) {
                        ref += '/' + this.regionSlug
                    }
                    if(this.departementSelected) {
                        ref += '/' + this.departementSelected
                    }
                    if(this.communeSelected) {
                        ref += '/' + this.communeSelected
                    }
                    if(comp) {
                        ref += comp;
                    }
                    history.pushState({}, 'Annonces', ref);
                })
            }
        },
        mounted(e) {
            this.regionSelected = $('#start').attr('data-id');
            if(this.regionSelected != 0) {
                this.regionSlug = $('#start').attr('data-slug');
                this.fillDepartements($('#start').attr('data-code'));
                const dep = $('#start').attr('data-departement');
                if(dep) {
                    this.departementSelected = dep;
                    this.fillCommunes(dep);
                }
                const com = $('#start').attr('data-commune');
                if(com) {
                    this.communeSelected = com;
                }
            }
            if($('#start').attr('data-page')) {
                this.submit(e, '?page=' + $('#start').attr('data-page'));
            } else {
                this.submit();
            }
        }
    }

    $('body').on('click', 'a.page-link', e => {
        e.preventDefault();
        app.__vue__.$refs.adComp.submit(e, '?' + ($(e.currentTarget).attr('href')).split('?')[1]);
    });

</script>

Analysons un peu ce code…

On a d’abord un template qui a pour but d’afficher 4 liste de sélection pour :

  • les catégories
  • les régions
  • les départements
  • les communes

Au niveau des données on voit qu’on reçoit en props :

  • une url
  • les catégories
  • les régions

Il va donc falloir renseigner des données dans la vue.

Au niveau des données internes du composant on a essentiellement :

  • departements : un tableau des départements
  • communes : un tableau des communes
  • ads : la liste des annonces en cours

Quand on a un changement dans l’une des listes il faut agir !

Si c’est un changement de catégorie on se contente d’envoyer la requête de recherche :

onCategoryChange() {
    this.submit();
},

Si c’est un changement de région c’est plus complexe, en effet il faut alors actualiser la liste des départements :

onRegionChange() {
    ...

    this.fillDepartements(code);

    ...

    this.submit();
},

Pour remplir la liste des département on fait appel à l’API comme on l’a vue pour les factories :

fillDepartements(code) {
    if(code) {
        let that = this;
        $.get('https://geo.api.gouv.fr/regions/' + code + '/departements', function(data) {
            that.departements = data;
        });
    }
    this.departementSelected = 0;
},

C’est le même principe pour un changement de département avec la liste des communes.

Dans tous les cas on lance la requête au serveur :

submit(e, comp = '') {
    $.ajax({
        method: 'post',
        url: this.url + comp,
        data: {
            'category': this.categorySelected,
            'region': this.regionSelected,
            'departement': this.departementSelected,
            'commune': this.communeSelected,
            '_token': $('meta[name="csrf-token"]').attr('content')
        }
    })
    .done((data) => {
        this.ads = data;
        let ref = '/annonces';
        if(this.regionSelected) {
            ref += '/' + this.regionSlug
        }
        if(this.departementSelected) {
            ref += '/' + this.departementSelected
        }
        if(this.communeSelected) {
            ref += '/' + this.communeSelected
        }
        if(comp) {
            ref += comp;
        }
        history.pushState({}, 'Annonces', ref);
    })
}

C’est une requête POST avec l’url /annonces/recherche. On a prévu dans les routes que ça arrive sur la méthode search du contrôleur qui n’existe pas encore.

On transmet les 4 paramètres :

  • catégorie
  • région
  • département
  • commune

Et on ajoute le token pour la sécurité.

Au retour on actualise la variable ads qui affiche ainsi la liste des annonces dans le template :

<span v-html="ads"></span>

Le serveur renvoie aussi la pagination qu’on affiche telle quelle. Mais comment gérer cette pagination ? Il faut déclencher la méthode submit du composant. Quand on utilise Vue.js on a parfois des soucis de communication quand on se situe à l’extérieur du système. Ce souci est géré ici :

$('body').on('click', 'a.page-link', e => {
    e.preventDefault();
    app.__vue__.$refs.adComp.submit(e, '?' + ($(e.currentTarget).attr('href')).split('?')[1]);
});

J’ai dû un peu grater dans le code de Vue.js pour écrire ces lignes mais ainsi ça fonctionne. On va voir qu’li faut quand même créer une réfénrence dans la vue.

La vue adsvue

Il est temps maintenant de créer la vue :

Au passage j’ai supprimer la vue welcome qui ne nous sert pas.

Voici le code :

@extends('layouts.app')

@section('content')
<div id="app">
    @if($region)
        <div id="start"
            data-id={{ $region->id }}
            data-code="{{ $region->code }}"
            data-slug="{{ $region->slug }}"
        @if($departementCode)
            data-departement="{{ $departementCode }}"
            @if($communeCode)
                data-commune="{{ $communeCode }}"
            @endif
        @endif
    @else
        <div id="start" data-id="0"
    @endif
    @if($page != 0)
        data-page="{{ $page }}"
    @endif
    ></div>
    <ad
        url="{{ route('annonces.search') }}"
        :categories="{{ $categories }}"
        :regions="{{ $regions }}"
        ref="adComp"
    ></ad>
</div>
@endsection

@section('script')
    <script src="{{ asset('js/vue.js') }}"></script>
@endsection

On voit qu’on renseigne les props du composant ainsi que la fameuse référence pour assurer le fonctionnement de la pagination qu’on a vu précédemment.

On prévoie aussi les données de départ pour le composant :

  • la région
  • la page

Pourquoi ne pas envoyer ça directement en props ? Tout simplement que dans le cycle de construction du composant ça ne fonctionne malheureusment pas. Alors il faut ruser… Ensuite dans la méthode mounted du composant on vient lire ces données :

mounted(e) {
    this.regionSelected = $('#start').attr('data-id');
    if(this.regionSelected != 0) {
        this.regionSlug = $('#start').attr('data-slug');
        this.fillDepartements($('#start').attr('data-code'));
        const dep = $('#start').attr('data-departement');
        if(dep) {
            this.departementSelected = dep;
            this.fillCommunes(dep);
        }
        const com = $('#start').attr('data-commune');
        if(com) {
            this.communeSelected = com;
        }
    }
    if($('#start').attr('data-page')) {
        this.submit(e, '?page=' + $('#start').attr('data-page'));
    } else {
        this.submit();
    }
}

C’est un peu accrobatique mais je n’ai pas trouvé mieux…

Un repository

Pour être un peu organisés on va créer un repository pour gérer les annonces :

On commence à le remplir avec notre recherche :

<?php

namespace App\Repositories;

use App\Models\Ad;
use Carbon\Carbon;

class AdRepository
{
    /**
     * Search.
     *
     * @param \Illuminate\Http\Request $request
     */
    public function search($request)
    {
        $ads = Ad::query();

        if($request->region != 0) {
            $ads = Ad::whereHas('region', function ($query) use ($request) {
                $query->where('regions.id', $request->region);
            })->when($request->departement != 0, function ($query) use ($request) {
                return $query->where('departement', $request->departement);
            })->when($request->commune != 0, function ($query) use ($request) {
                return $query->where('commune', $request->commune);
            });
        }

        if($request->category != 0) {
            $ads->whereHas('category', function ($query) use ($request) {
                $query->where('categories.id', $request->category);
            });
        }

        return $ads->with('category', 'photos')
            ->whereActive(true)
            ->latest()
            ->paginate(3);
    }
}

Selon catégorie, région, département et commune on extrait les annonces avec une pagination de 3 pages.

Le contrôleur et la vue partielle

Le contrôleur

On en revient au contrôleur où il nous faut la méthode search qui reçoit la demande du composant dans la vue et qui doit utiliser le repository pour aller chercher les annonces correspondantes :

use App\Repositories\AdRepository;

class AdController extends Controller
{
    /**
     * Ad repository.
     *
     * @var App\Repositories\AdRepository
     */
    protected $adRepository;

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct(AdRepository $adRepository)
    {
        $this->adRepository = $adRepository;
    }

    /**
     * Search ads.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  String  $slug
     * @return \Illuminate\Http\Response
     */
    public function search(Request $request)
    {
        setlocale (LC_TIME, 'fr_FR');

        $ads = $this->adRepository->search($request);

        return view('partials.ads', compact('ads'));
    }

On voit qu’on fait appel à une vue partielle pour constituer la réponse.

La vue partielle

On crée cette vue :

Avec ce code :

@foreach($ads as $ad)
<a href="{{ route('annonces.show', $ad->id) }}" class="blockAd">
        <div class="card d-flex flex-row">
            <div class="card-header">
                @if($ad->photos->isNotEmpty())
                    <img class="rounded" src="{{ asset('thumbs/' . $ad->photos->first()->filename) }}" alt="">
                @else
                    <img src="{{ asset('thumbs/question.jpg') }}" alt="">
                @endif
            </div>
            <div class="card-body">
                <h4 class="card-title">{{ $ad->title }}</h4>
                <p class="card-text">{{ $ad->category->name }}</p>
                <p class="card-text">
                    {{ $ad->commune_name . ' (' . $ad->commune_postal . ')'}}<br>
                    {{ $ad->created_at->calendar() }}
                </p>
            </div>
        </div>
    </a>
    <br>
@endforeach
<div class="d-flex">
    <div class="mx-auto">
        {{ $ads->links() }}
    </div>
</div>

On constitue ainsi le code HTML pour la liste des annonces qui correspondent à la recherche ainsi que la pagination en partie inférieure.

Le résultat

Maintenant on a tout mis en place il ne nous reste plus qu’à cliquer sur une région et…

Maintenant on peut par exemple sélectionner un département pour n’avoir que les annonces de celui-ci :

Faites des essais en changeant la catégorie, la région… Normalement à chaque fois les annonces s’actualisent et vous avez la pagination qui fonctionne s’il y a plus de 3 annonces.

Remarquez aussi que l’url est actualisée à chaque fois pour qu’un rechargement de la page aboutisse au même résultat. c’est assuré par ce code dans le composant lorsque la requête de recherche renvoie un résultat positif :

.done(data => {
    this.ads = data;
    let ref = '/annonces';
    if(this.regionSelected) {
        ref += '/' + this.regionSlug
    }
    if(this.departementSelected) {
        ref += '/' + this.departementSelected
    }
    if(this.communeSelected) {
        ref += '/' + this.communeSelected
    }
    if(comp) {
        ref += comp;
    }
    history.pushState({}, 'Annonces', ref);
})

Conclusion

On a désormais un affichage d’une liste d’annonce en fonction de critères de recherches : catégorie et localisation. On a aussi la pagination s’il y en a beaucoup. On a vu que Vue.js s’intègre bien dans Laravel même s’il faut parfois un peu jongler. J’ai utilisé JQuery pour les requêtes HTTP puisqu’il est déjà prévu dans le projet.

Dans le prochain article on créera la vue pour afficher une annonce avec tous ses détails. On verra qu’il faudra prévoir quelques précautions pour ne pas afficher une annonce non active. Enfin on va donner la possibilité au visiteur de laisser un message à l’émetteur de l’annonce…