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.