Laravel

Un framework qui rend heureux

Voir cette catégorie
Vers le bas
Shopping : le panier
Dimanche 10 mai 2020 18:40

Dans cet article on va afficher les produits, permettre leur sélection et utiliser un panier pour mémoriser et comptabiliser les achats.

Vous pouvez télécharger un ZIP du projet ici.

Affichage de la boutique

La première chose à faire est d'afficher les produits sur la page d'accueil. Pour le moment on a la page d'accueil standard de Laravel avec la vue welcome. On va supprimer cette vue qui ne nous sert à rien et on va recycler la vue home.

Dans le contrôleur HomeController on va prévoir ce code (on supprime au passage le middleware guest) :
<?php

namespace App\Http\Controllers;

use App\Models\Product;

class HomeController extends Controller
{
    /**
     * Show home page
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $products = Product::whereActive(true)->get();

        return view('home', compact('products'));
    }
}

Pour cette application j'ai décidé de me passer de repositories, c'est un choix délibéré. On a une totale liberté sur l'organisation de son code.

On change la route pour l'accueil :
Route::get('/', 'HomeController@index')->name('home');

Comme on a peu de produits on se passe de pagination. On charge donc tous les produits actifs dans une variable $products que l'on envoie dans la vue home. On change le code de cette vue pour utiliser le layout et afficher les produits :

@extends('layouts.app')

@section('content')
<div class="container">
  
  <div class="row">
    <div class="col s12 cards-container">
      @foreach($products as $product)
        <div class="card">
          <div class="card-image">
            @if($product->quantity)
              <a href="#">
            @endif
              <img src="/images/thumbs/{{ $product->image }}">
            @if($product->quantity) </a> @endif
          </div>          
          <div class="card-content center-align">
            <p>{{ $product->name }}</p>
            @if($product->quantity)
              <p><strong>{{ number_format($product->price, 2, ',', ' ') }} € TTC</strong></p>
            @else
              <p class="red-text"><strong>Produit en rupture de stock</strong></p>
            @endif
          </div>
        </div>
      @endforeach
    </div>
  </div>

</div>
@endsection
Et pour la répartition des produits sur la page un peu de style pour un effet Masonry (sass/app.scss) :
.cards-container {
  .card {
    display: inline-block;
    overflow: visible;
    width: 100%;
  }
}

@media only screen and (max-width: 600px) {
  .cards-container {
    -webkit-column-count: 1;
    -moz-column-count: 1;
    column-count: 1;
  }
}
@media only screen and (min-width: 601px) {
  .cards-container {
    -webkit-column-count: 2;
    -moz-column-count: 2;
    column-count: 2;
  }
}
@media only screen and (min-width: 993px) {
  .cards-container {
    -webkit-column-count: 3;
    -moz-column-count: 3;
    column-count: 3;
  }
}
@media only screen and (min-width: 1200px) {
  .cards-container {
    -webkit-column-count: 4;
    -moz-column-count: 4;
    column-count: 4;
  }
}
On se retrouve avec 1 à 4 produits par ligne selon la largeur de l'affichage : On complète aussi les liens dans la barre du layout :
<a href="{{ route('home') }}" class="brand-logo"><img src="/images/logo1.png" width="210px" alt="Logo"></a>
<a href="{{ route('home') }}" data-target="mobile" class="sidenav-trigger"><i class="material-icons">menu</i></a>
Si un produit est en rupture de stock c'est signalé :

Le détail des produits

Quand on clique sur un produit il faut afficher une page avec l'image haute définition, la description, la quantité désirée et un bouton pour ajouter au panier.

On va créer un contrôleur pour les produits :
php artisan make:controller ProductController
Avec ce code :
<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\Request;

class ProductController extends Controller
{
    /**
     * Display the specified resource.
     *
     * @param  \App\Models\Product  $product
     * @return \Illuminate\Http\Response
     */
    public function __invoke(Request $request, Product $produit)
    {
        if($produit->active || $request->user()->admin) {
            return view('products.show', compact('produit'));
        }

        return redirect(route('home'));
    }
}
On affiche la page produit que si celui-ci est actif ou si c'est un administrateur qui veut la voir. On prévoit la route :
Route::name('produits.show')->get('produits/{produit}', 'ProductController');
On crée la vue dans un dossier : Avec ce code :
@extends('layouts.app')

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

  <div class=row>
    <div class="col s12 m6">
      <img style="width: 100%" src="/images/{{ $produit->image }}">
    </div>
    <div class="col s12 m6">
      <h4>{{ $produit->name }}</h4>
      <p><strong>{{ number_format($produit->price, 2, ',', ' ') }} € TTC</strong></p>
      <p>{{ $produit->description }}</p>
      <form  method="POST" action="#">
        @csrf
        <div class="input-field col">
          <input type="hidden" id="id" name="id" value="{{ $produit->id }}">
          <input id="quantity" name="quantity" type="number" value="1" min="1">
          <label for="quantity">Quantité</label>        
          <p>
            <button class="btn waves-effect waves-light" style="width:100%" type="submit" id="addcart">Ajouter au panier
              <i class="material-icons left">add_shopping_cart</i>
            </button>
          </p>    
        </div>    
      </form>
    </div>
  </div>

</div>
@endsection
Dans la vue home on ajoute le lien pour afficher la page d'un produit :
<a href="{{ route('produits.show', $product->id) }}">
Maintenant le clic sur un produit ouvre sa page de détail : Il ne nous reste plus qu'à faire fonctionner le panier !

Mise en place du panier

Pour le panier on va utiliser ce package.
composer require darryldecode/cart
On crée un contrôleur de ressource :
php artisan make:controller CartController --resource
On n'utilisera que 4 méthodes donc voici les routes :
Route::resource('panier', 'CartController')->only(['index', 'store', 'update', 'destroy']);
On va compléter la vue products.show avec une vue modale, le lien du formulaire, et du Javascript pour le traitement :
@extends('layouts.app')

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

  @if(session()->has('cart'))
    <div class="modal">
      <div class="modal-content center-align">
        <h5>Produit ajouté au panier avec succès</h5>
        <hr>
        <p>Il y a {{ $cartCount }} @if($cartCount > 1) articles @else article @endif dans votre panier pour un total de <strong>{{ number_format($cartTotal, 2, ',', ' ') }} € TTC</strong> hors frais de port.</p>
        <p><em>Vous avez la possibilité de venir chercher vos produits sur place, dans ce cas vous cocherez la case correspondante lors de la confirmation de votre commande et aucun frais de port ne vous sera facturé.</em></p>
        <div class="modal-footer">     
          <button class="modal-close btn waves-effect waves-light left" id="continue">
            Continuer mes achats
          </button>
          <a href="{{ route('panier.index') }}" class="btn waves-effect waves-light">
            <i class="material-icons left">check</i>
            Commander          
          </a>
        </div>
      </div>
    </div>
  @endif

  <div class=row>
    <div class="col s12 m6">
      <img style="width: 100%" src="/images/{{ $produit->image }}">
    </div>
    <div class="col s12 m6">
      <h4>{{ $produit->name }}</h4>
      <p><strong>{{ number_format($produit->price, 2, ',', ' ') }} € TTC</strong></p>
      <p>{{ $produit->description }}</p>
      <form  method="POST" action="{{ route('panier.store') }}">
        @csrf
        <div class="input-field col">
          <input type="hidden" id="id" name="id" value="{{ $produit->id }}">
          <input id="quantity" name="quantity" type="number" value="1" min="1">
          <label for="quantity">Quantité</label>        
          <p>
            <button class="btn waves-effect waves-light" style="width:100%" type="submit" id="addcart">Ajouter au panier
              <i class="material-icons left">add_shopping_cart</i>
            </button>
          </p>    
        </div>    
      </form>
    </div>
  </div>

</div>
@endsection

@section('javascript')
  <script>
    @if(session()->has('cart'))
      document.addEventListener('DOMContentLoaded', () => {      
        const instance = M.Modal.init(document.querySelector('.modal'));
        instance.open();    
      });
    @endif    
  </script>
@endsection
Dans le contrôleur CartController on code la méthode store :
use App\Models\Product;
use Cart;

...

public function store(Request $request)
{
    $product = Product::findOrFail($request->id);
      
    Cart::add([
        'id' => $product->id,
        'name' => $product->name,
        'price' => $product->price,
        'quantity' => $request->quantity,
        'attributes' => [],
        'associatedModel' => $product,
      ]
    );

    return redirect()->back()->with('cart', 'ok');
}

On récupère le produit concerné et on renseigne le panier, on renvoie la même vue en flashant la variable cart.

On va aussi envoyer les informations de quantité de produits dans le panier et de coût total mais comme ça va servir dans plusieurs situations on passe par un composeur de vue dans AppServiceProvider :

use Illuminate\Support\Facades\View;
use Cart;

...

public function boot()
{
    View::composer(['layouts.app', 'products.show'], function ($view) {
        $view->with([
            'cartCount' => Cart::getTotalQuantity(), 
            'cartTotal' => Cart::getTotal(),
        ]);
    });
}
Maintenant quand on utilise le bouton "Ajouter au panier" au retour ça ouvre la page modale : Voyons un peu comment agit le Javascript dans cette vue... Au chargement de la vue on a :
@if(session()->has('cart'))
  document.addEventListener('DOMContentLoaded', () => {      
    const instance = M.Modal.init(document.querySelector('.modal'));
    instance.open();    
  });
@endif

Si on a flashé la variable cart dans la session on ouvre la page modale avec la librairie de Materialize.

Si on clique sur "Continuer mes achats" la page modale se ferme et on se retrouve avec la vue du produit.

Maintenant ce qui serait bien c'est d'informer l'utilisateur qu'il a un panier actif avec le nombre de produits dedans. On va le faire dans la barre de menu (layout) en prévoyant les deux emplacement (barre normale et barre latérale pour mobiles) :

<ul class="right hide-on-med-and-down">
@if($cartCount)
  <li>
    <a class="tooltipped" href="{{ route('panier.index') }}" data-position="bottom" data-tooltip="Voir mon panier"><i class="material-icons left">shopping_cart</i>Panier({{ $cartCount }})</a>
  </li>
@endif
@guest  

...

<ul class="sidenav" id="mobile">
  @if($cartCount)
    <li>
      <a class="tooltipped" href="{{ route('panier.index') }}" data-position="bottom" data-tooltip="Voir mon panier">Panier({{ $cartCount }})</a>
    </li>
  @endif
  @guest

Le panier

Maintenant on va coder pour voir le panier. On crée une vue :

Avec ce code :
@extends('layouts.app')

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

  <div class="row">
    <div class="col s12">
      <div class="card">        
        <div class="card-content">
          <div id="wrapper">          
            @if($total)
              <span class="card-title">Mon panier</span>            
              @foreach ($content as $item)
                <hr><br>
                <div class="row">
                  <form action="{{ route('panier.update', $item->id) }}" method="POST">
                    @csrf
                    @method('PUT')
                    <div class="col m6 s12">{{ $item->name }}</div>
                    <div class="col m3 s12"><strong>{{ number_format($item->quantity * $item->price, 2, ',', ' ') }} €</strong></div>
                    <div class="col m2 s12">
                      <input name="quantity" type="number" style="height: 2rem" min="1" value="{{ $item->quantity }}">
                    </div>
                  </form>
                  <form action="{{ route('panier.destroy', $item->id) }}" method="POST">
                    @csrf
                    @method('DELETE')
                    <div class="col m1 s12"><i class="material-icons deleteItem" style="cursor: pointer">delete</i></div>
                  </form>              
                </div>
              @endforeach
              <hr><br>
              <div class="row" style="background-color: lightgrey">
                <div class="col s6">
                  Total TTC (hors livraison)
                </div>
                <div class="col s6">
                  <strong>{{ number_format($total, 2, ',', ' ') }} €</strong>
                </div>
              </div>
            @else
            <span class="card-title center-align">Le panier est vide</span>
            @endif
          </div>        
          <div id="loader" class="hide">
            <div class="loader"></div>
          </div>
        </div>
        <div id="action" class="card-action">
          <p>
            <a  href="{{ route('home') }}">Continuer mes achats</a>
            @if($total)
              <a href="#">Commander</a>
            @endif
          </p>
        </div>
      </div>
    </div>
  </div>

</div>
@endsection

@section('javascript')
  <script>

    document.addEventListener('DOMContentLoaded', () => {
      const quantities = document.querySelectorAll('input[name="quantity"]');
      quantities.forEach( input => {
        input.addEventListener('input', e => {
          if(e.target.value < 1) {
            e.target.value = 1;
          } else {
            e.target.parentNode.parentNode.submit();
            document.querySelector('#wrapper').classList.add('hide');
            document.querySelector('#action').classList.add('hide');
            document.querySelector('#loader').classList.remove('hide');
          }
        });
      }); 

      const deletes = document.querySelectorAll('.deleteItem');
      deletes.forEach( icon => {
        icon.addEventListener('click', e => {
          e.target.parentNode.parentNode.submit();
          document.querySelector('#wrapper').classList.add('hide');
          document.querySelector('#loader').classList.remove('hide');
        });
      }); 
    });
    
  </script>
@endsection
Et dans le contrôleur CartController on complète la méthode index :
public function index()
{
    $content = Cart::getContent();
    $total = Cart::getTotal();

    return view('cart.index', compact('content', 'total'));
}

Maintenant quand on clique sur "Commander" (ou si on clique sur le lien du panier dans le menu) on obtient la page avec le contenu du panier :

Là l'utilisateur peut modifier les quantités et même supprimer un produit.

Pour que ça fonctionne on va compléter le contrôleur CartController :
public function update(Request $request, $id)
{
    Cart::update($id, [
        'quantity' => ['relative' => false, 'value' => $request->quantity],
    ]);

    return redirect(route('panier.index'));
}

public function destroy($id)
{
    Cart::remove($id);

    return redirect(route('panier.index'));
}

Pour la mise à jour de la quantité d'un produit il faut bien préciser relative à false sinon ça change relativement à la valeur mémorisée or là on envoie la nouvelle quantité.

Pour faire patienter l'utilisateur pendant les mises à jour et pour éviter qu'il clique encore quelque part on fait apparaître un loader. Pour que ça fonctionne on va ajouter un peu de style dans app.scss (n'oubliez pas de relancer la compilation) :

#loader {
  margin: 40px;
}
.loader {
  margin: auto;
  border: 16px solid #e3e3e3;
  border-radius: 50%;
  border-top: 16px solid #1565c0;
  width: 120px;
  height: 120px;
  animation: spin 2s linear infinite;
}
@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
Une petite animation fait patienter l'utilisateur : Le Javascript a plusieurs fonctions :
  • mettre en route l'animation
  • empêcher la saisie d'une valeur inférieure à 1
  • lancer la soumission des formulaires

Conclusion

On a maintenant une boutique qui sait afficher ses produits, montrer le détail d'un produit, permet d'ajouter un produit au panier en choisissant la quantité voulue, d'afficher le panier en pouvant jouer sur les quantités. Dans le prochain article on mettra en place l'enregistrement d'une commande.

   


Par bestmomo

Nombre de commentaires : 46