Réaliser un panier 100% JavaScript

Une boutique de e-commerce nécessite la plupart du temps un panier. De nombreuses solutions peuvent répondre à ce besoin. Il est possible de mettre en place un panier côté serveur, d’adjoindre un plugin au framwork ou CMS utilisé pour le site etc. Cependant, nous allons voir comment programmer un panier 100% en JavaScript sans utiliser aucun plugin ni code côté serveur.

La panier côté client présente certains avantages. Il ne nécessite pas le rechargement de la page à chaque ajout d’article et il est indépendant du code utilisé pour bâtir le site. Ainsi, on l’adjoindra sans problème à des CMS comme Wordpress ou Joomla sans devoir modifier le code du CMS ou installer de plugin.

Il existe bien entendu des librairies JavaScript open source qui permettent d’avoir un panier clef en main. Néanmoins, développer sa solution soit-même donne l’assurance d’obtenir quelque chose qui correspond à 100% à ses besoins tout en restant léger.

J’ai à la base créé ce panier pour le site Alphapole, spécialisé dans la magnétothérapie et la purification de l’eau. J’ai conçu le site avec Wordpress, néanmoins, n’étant pas fan des plugins e-commerce du CMS, j’ai opté pour un panier sur-mesure. Vous pouvez d’ailleurs vous rendre sur le site pour voir ce dernier en action.

Je voulais éviter les rechargements de page, lesquels nuisent à l’expérience utilisateur – spécialement sur mobile où le réseau peut être lent. Une solution client-side basée sur jQuery semblait alors une bonne idée. En outre, les aimants étant lourds, il était impératif de prendre en compte le poids de la marchandise dans les frais de port. Cet article se basera sur des exemples du site d’Alphapole. Ce dernier repose sur Bootstrap, j’enleverai cependant les classes et éléments HTML de présentation pour plus de clarté. Assez discuté, commençons !

Le HTML de base

Le panier se compose de trois parties :

Les articles

Il s’agit simplement d’un bouton permettant l’ajout au panier, et d’un champ select qui propose de sélectionner une quantité allant de 1 à 9. L’exemple ci-dessous est extrait des Pastilles Physiomag.

<label for="q">Quantité: </label>
<select id="qt" name="q">
  <option value="1">1</option>
  <option value="2">2</option>
  <option value="3">3</option>
  <option value="4">4</option>
  <option value="5">5</option>
  <option value="6">6</option>
  <option value="7">7</option>
  <option value="8">8</option>
  <option value="9">9</option>
</select><br>
<button type="button" class="add-to-cart" data-id="432" data-name="Pastilles" data-price="29,00" data-weight="97" data-url="/produit/pastilles-anti-douleurs/">Ajouter au panier</button>

Il n’y a rien de particulier à dire sur le select et son label. Le JavaScript viendra simplement récupérer la valeur de input dont l’id est “qt”. Par ailleurs, la sélection de la quantité n’est proposée que sur les fiches produit individuelles et non sur les pages listant plusieurs produits. Si on veut proposer la quantité sur ces dernières, c’est possible moyennant quelques modifications. Attention, il ne doit pas y avoir deux fois le même id sur une page.

Le bouton – qui est le code minimal pour que le panier soit fonctionnel – concentre l’essentiel. Toutes les informations liées à l’article sont stockées dans les attributs data-* :

Le widget panier

Le code de l’icône ou widget du panier est assez simple :

<div class="dropdown">
  <div id="cart">
    <p><span id="in-cart-items-num">0</span> Articles</p>
  </div>

  <ul id="cart-dropdown" hidden>
    <li id="empty-cart-msg"><a>Votre panier est vide</a></li>
    <li class="go-to-cart hidden"><a href="/panier/">Voir le panier</a></li>
  </ul>
</div>

Il s’agit simplement d’une balise p qui indique le nombre d’articles dans le panier. Au survol de celle-ci apparaît une liste ul. La liste possède par défaut un message indiquant que le panier est vide et un lien – en display:hidden grâce à l’attribut HTML5 hidden tant que le panier est vide – permettant d’aller au panier.

Pour faire apparaître la liste des produits ajoutés lors du survol du widget, on peut utiliser du CSS ou du JavaScript. Dans mon infinie bonté, je vous donnes les deux possibilités :

/* CSS */
.dropdown:hover > #cart-dropdown{
  display: block;
}

/* JS */
// comportement du panier au survol pour affichage de son contenu
var timeout;

$('#cart').on({
  mouseenter: function() {
    $('#cart-dropdown').show();
  },
  mouseleave: function() {
    timeout = setTimeout(function() {
      $('#cart-dropdown').hide();
    }, 200);
  }
});

// laisse le contenu ouvert à son survol
// le cache quand la souris sort
$('#cart-dropdown').on({
  mouseenter: function() {
    clearTimeout(timeout);
  },
  mouseleave: function() {
    $('#cart-dropdown').hide();
  }
});

J’aime l’élégance et la simplicité du CSS. La solution JavaScript offre néanmoins beaucoup plus de souplesse. Par exemple, on fixe ici un certain temps entre le moment ou la souris quitte le #cart et où #cart-dropdown disparaît. Ça nous permet de gérer le cas où la liste n’est pas directement accolée au p.

La page panier

La page dédiée au panier présente plus de détails que le widget et ajoute des éléments de contrôle.

<table class="table">
  <thead>
    <tr><th>Article</th><th>Prix</th><th>Quantité</th></tr>
  </thead>
  <tbody id="cart-tablebody">
  </tbody>
</table>

<p>Sous total : <span class="subtotal"></span>€</p>

<button id="confirm-command">Passer la commande</button>

Un tableau regroupe les en-têtes de nos différentes colonnes, le corps du tableau et le sous total, lesquels seront peuplés par le JS. Enfin, nous trouvons aussi un bouton permettant de confirmer la commande et de passer à la phase de paiement.

Les fonctions de bases

Nous avons besoin de stocker certaines informations afin que notre panier ne se vide pas à chaque changement de page ou lorsque l’internaute quitte le site puis y revient plus tard. On a le choix entre l’api localStorage et les classiques cookies. Nous allons opter pour les cookies car ils sont mieux supportés que le localStorage. Le plus gros problème de ce dernier survient dans Safari en mode navigation privé : le navigateur le rend tout bonnement indisponible, comme s’il n’était pas supporté. En outre, les cookies permettent de spécifier une date de péremption. Ainsi, quelques jours après sa visite, si un internaute n’a toujours pas passé commande, on peut estimer que son panier n’est plus valable.

Les fonctions permettant de gérer les cookies en JavaScript ne sont pas ultra ergonomiques. Nous allons créer nos propres fonctions pour enregistrer et récupérer facilement des éléments de notre panier.

setCookie

Commençons par créer la fonction qui permet d’enregistrer des données dans un cookie :

function setCookie(cname, cvalue, exdays) {
  var d = new Date();
  d.setTime(d.getTime() + (exdays*24*60*60*1000));
  var expires = "expires="+d.toUTCString();

  // règle le pb des caractères interdits
  if ('btoa' in window) {
    cvalue = btoa(cvalue);
  }

  document.cookie = cname + "=" + cvalue + "; " + expires+';path=/';
}

La fonction prend trois paramètres : le nom du cookie, sa valeur et le nombre de jours avant que celui-ci n’expire. Petite explication pour la partie sur les caractères interdits. btoa permet d’encoder une chaine de caractère en base64, cela évite les problèmes de certains navigateurs avec les caractères non Unicode [en] (notamment Safari et les accents). Si la fonction est supportée dans le navigateur, on encode le contenu du cookie en base64.

On créé aussi une petite fonction saveCart pour sauvegarder d’un coup tout notre panier :

function saveCart(inCartItemsNum, cartArticles) {
  setCookie('inCartItemsNum', inCartItemsNum, 5);
  setCookie('cartArticles', JSON.stringify(cartArticles), 5);
}

On ne peut stocker que des chaines de caractères dans les cookies, on sérialise donc notre tableau cartArticles avec JSON.stringify avant de l’enregistrer.

getCookie

On créé ensuite la fonction pour récupérer les cookies :

function getCookie(cname) {
  var name = cname + "=";
  var ca = document.cookie.split(';');

  for(var i = 0; i < ca.length; i++) {
    var c = ca[i];
    while (c[0] == ' ') {
      c = c.substring(1);
    }

    if (c.indexOf(name) != -1) {
      if ('btoa' in window) {
        return atob(c.substring(name.length,c.length));
      }
      else {
        return c.substring(name.length,c.length);
      }
    }
  }
  return false;
}

Cette fonction prend en paramètre le nom du cookie et retourne sa valeur. Si btoa est supporté, cela veut dire qu’on a enregistré du base64 dans le cookie, on le retransforme alors en texte avec la fonction atob, sinon, on récupère simplement le texte non encodé.

Récupérer les articles et peupler le widget

Nous allons commencer par le plus simple : l’initialisation des variables, la récupération des informations dans les cookies et leur affichage. Nous verrons ensuite comment ajouter de nouveaux articles au panier.

Dès que nous changeons de page, nous devons récupérer tous les articles stockés dans les cookies et les placer dans le widget.

// variables pour stocker le nombre d'articles et leurs noms
var inCartItemsNum;
var cartArticles;

// affiche/cache les éléments du panier selon s'il contient des produits
function cartEmptyToggle() {
  if (inCartItemsNum > 0) {
    $('#cart-dropdown .hidden').removeClass('hidden');
    $('#empty-cart-msg').hide();
  }

  else {
    $('#cart-dropdown .go-to-cart').addClass('hidden');
    $('#empty-cart-msg').show();
  }
}

// récupère les informations stockées dans les cookies
inCartItemsNum = parseInt(getCookie('inCartItemsNum') ? getCookie('inCartItemsNum') : 0);
cartArticles = getCookie('cartArticles') ? JSON.parse(getCookie('cartArticles')) : [];

cartEmptyToggle();

// affiche le nombre d'article du panier dans le widget
$('#in-cart-items-num').html(inCartItemsNum);

// hydrate le panier
var items = '';
cartArticles.forEach(function(v) {
   items += '<li id="'+ v.id +'"><a href="'+ v.url +'">'+ v.name +'<br><small>Quantité : <span class="qt">'+ v.qt +'</span></small></a></li>';
});

$('#cart-dropdown').prepend(items);

Aux lignes 19 et 20 on utilise des conditions ternaires. Ligne 19, si le cookie ne retourne rien (il est vide ou inexistant), on attribue 0 par défaut à la variable. De plus, on s’assure à la ligne 19 que notre valeur est bien un entier et non une chaine de caractères. À la ligne 20, si le cookie ne retourne rien, on créé un objet vide. Vous remarquerez aussi à la ligne 20 qu’on parse la valeur récupérée du cookie avec JSON.parse, c’est parce que nous avons encodé le tableau d’articles en json dans la fonction saveCart.

À la ligne 30, vous notez que nous stockons d’abord nos articles dans une variable avant de les ajouter au DOM. On pourrait placer les éléments un par un dans le DOM en mettant prepend dans la boucle, mais c’est beaucoup moins performant que de le faire en une seule insertion.

Vous constaterez aussi à la ligne 30 que le nom des article est un lien qui mène directement à la fiche produit. Cela permet à l’internaute de rapidement retrouver toutes les informations à propos d’un produit qu’il a mis dans son panier.

Ajout d’un article au panier

Lorsque l’on clique sur le bouton d’ajout au panier #add-to-cart que nous avons mis en place plus haut, le JavaScript d’ajout s’exécute. Le code ci-dessous vient se placer à la suite du précédent :

[…]

// click bouton ajout panier
$('.add-to-cart').click(function() {

  // récupération des infos du produit
  var $this = $(this);
  var id = $this.attr('data-id');
  var name = $this.attr('data-name');
  var price = $this.attr('data-price');
  var weight = $this.attr('data-weight');
  var url = $this.attr('data-url');
  var qt = parseInt($('#qt').val());
  inCartItemsNum += qt;

  // mise à jour du nombre de produit dans le widget
  $('#in-cart-items-num').html(inCartItemsNum);

  var newArticle = true;

  // vérifie si l'article est pas déjà dans le panier
  cartArticles.forEach(function(v) {
    // si l'article est déjà présent, on incrémente la quantité
    if (v.id == id) {
      newArticle = false;
      v.qt += qt;
      $('#'+ id).html('<a href="'+ url +'">'+ name +'<br><small>Quantité : <span class="qt">'+ v.qt +'</span></small></a>');
    }
  });

  // s'il est nouveau, on l'ajoute
  if (newArticle) {
    $('#cart-dropdown').prepend('<li id="'+ id +'"><a href="'+ url +'">'+ name +'<br><small>Quantité : <span class="qt">'+ qt +'</span></small></a></li>');

    cartArticles.push({
      id: id,
      name: name,
      price: price,
      weight: weight,
      qt: qt,
      url: url
    });
  }

  // sauvegarde le panier
  saveCart(inCartItemsNum, cartArticles);

  // affiche le contenu du panier si c'est le premier article
  cartEmptyToggle();
});

Les commentaires sont assez explicites, rien de spécial jusqu’à la ligne 19 si ce n’est que l’on cache $(this) dans une variable pour les performances. À la ligne 19, on initialise notre variable permettant de savoir si l’article est nouveau où s’il est déjà dans le panier (c’est basé sur l’id, nous en avons parlé plus haut rappelez-vous).

Si l’id du produit est déjà présent dans l’array (ligne 24), c’est que l’article n’est pas nouveau, on passe donc la variable à false, on incrémente la quantité de cet article dans le tableau et on met à jour le widget. Sinon, la variable newArticle reste vraie, on ajoute alors l’article au widget et on fait un push pour ajouter le nouvel article à cartArticles.

Rendu de la page panier

Avant d’attaquer cette partie, il est important d’avoir à l’esprit que l’ordinateur exécute ses calculs en base 2. Ainsi, chaque nombre se voit attribuer un espace mémoire fini. Cette contrainte résulte en de nombreux arrondis lors des calculs sur les nombres, lesquels peuvent impacter le résultat final… Particulièrement en JavaScript. Pour plus de détails techniques, voici une explication assez synthétique sur StackOverflow [en].

// si on est sur la page ayant pour url monsite.fr/panier/
if (window.location.pathname == '/panier/') {
  var items = '';
  var subTotal = 0;
  var total;
  var weight = 0;

  /* on parcourt notre array et on créé les lignes du tableau pour nos articles :
  * - Le nom de l'article (lien cliquable qui mène à la fiche produit)
  * - son prix
  * - la dernière colonne permet de modifier la quantité et de supprimer l'article
  *
  * On met aussi à jour le sous total et le poids total de la commande
  */
  cartArticles.forEach(function(v) {
    // opération sur un entier pour éviter les problèmes d'arrondis
    var itemPrice = v.price.replace(',', '.') * 1000;
    items += '<tr data-id="'+ v.id +'">\
             <td><a href="'+ v.url +'">'+ v.name +'</a></td>\
             <td>'+ v.price +'€</td>\
             <td><span class="qt">'+ v.qt +'</span> <span class="qt-minus">–</span> <span class="qt-plus">+</span> \
             <a class="delete-item">Supprimer</a></td></tr>';
    subTotal += v.price.replace(',', '.') * v.qt;
    weight += v.weight * v.qt;
  });

  // on reconverti notre résultat en décimal
  subTotal = subTotal / 1000;

  // On insère le contenu du tableau et le sous total
  $('#cart-tablebody').empty().html(items);
  $('.subtotal').html(subTotal.toFixed(2).replace('.', ','));

  // lorsqu'on clique sur le "+" du panier
  $('.qt-plus').on('click', function() {
    var $this = $(this);

    // récupère la quantité actuelle et l'id de l'article
    var qt = parseInt($this.prevAll('.qt').html());
    var id = $this.parent().parent().attr('data-id');
    var artWeight = parseInt($this.parent().parent().attr('data-weight'));

    // met à jour la quantité et le poids
    inCartItemsNum += 1;
    weight += artWeight;
    $this.prevAll('.qt').html(qt + 1);
    $('#in-cart-items-num').html(inCartItemsNum);
    $('#'+ id + ' .qt').html(qt + 1);

    // met à jour cartArticles
    cartArticles.forEach(function(v) {
        // on incrémente la qt
        if (v.id == id){
            v.qt += 1;

            // récupération du prix
            // on effectue tous les calculs sur des entiers
            subTotal = ((subTotal * 1000) + (parseFloat(v.price.replace(',', '.')) * 1000)) / 1000;
        }
    });

    // met à jour la quantité du widget et sauvegarde le panier
    $('.subtotal').html(subTotal.toFixed(2).replace('.', ','));
    saveCart(inCartItemsNum, cartArticles);
  });

  // quantité -
  $('.qt-minus').click(function() {
    var $this = $(this);
    var qt = parseInt($this.prevAll('.qt').html());
    var id = $this.parent().parent().attr('data-id');
    var artWeight = parseInt($this.parent().parent().attr('data-weight'));

    if (qt > 1) {
      // maj qt
      inCartItemsNum -= 1;
      weight -= artWeight;
      $this.prevAll('.qt').html(qt - 1);
      $('#in-cart-items-num').html(inCartItemsNum);
      $('#'+ id + ' .qt').html(qt - 1);

      cartArticles.forEach(function(v) {
          // on décrémente la qt
          if (v.id == id) {
              v.qt -= 1;

              // récupération du prix
              // on effectue tous les calculs sur des entiers
              subTotal = ((subTotal * 1000) - (parseFloat(v.price.replace(',', '.')) * 1000)) / 1000;
          }
      });

      $('.subtotal').html(subTotal.toFixed(2).replace('.', ','));
      saveCart(inCartItemsNum, cartArticles);
    }
  });

  // suppression d'un article
  $('.delete-item').click(function() {
    var $this = $(this);
    var qt = parseInt($this.prevAll('.qt').html());
    var id = $this.parent().parent().attr('data-id');
    var artWeight = parseInt($this.parent().parent().attr('data-weight'));
    var arrayId = 0;
    var price;

    // maj qt
    inCartItemsNum -= qt;
    $('#in-cart-items-num').html(inCartItemsNum);

    // supprime l'item du DOM
    $this.parent().parent().hide(600);
    $('#'+ id).remove();

    cartArticles.forEach(function(v) {
        // on récupère l'id de l'article dans l'array
        if (v.id == id) {
            // on met à jour le sous total et retire l'article de l'array
            // as usual, calcul sur des entiers
            var itemPrice = v.price.replace(',', '.') * 1000;
            subTotal -= (itemPrice * qt) / 1000;
            weight -= artWeight * qt;
            cartArticles.splice(arrayId, 1);

            return false;
        }
        arrayId++;
    });

    $('.subtotal').html(subTotal.toFixed(2).replace('.', ','));
    saveCart(inCartItemsNum, cartArticles);
    cartEmptyToggle();
  });
}

Vous remarquerez à la ligne 22 qu’on formate notre prix au format anglais (on remplace la virgule décimale par un point). Ça nous permet de le transformer en nombre et de faire des opérations dessus (ici une multiplication et une addition). On le reformate ensuite à la ligne 28 pour le rendre plus agréable aux yeux de nos chers clients. Vous noterez par ailleurs que nous veillons toujours à ce que qt et artWeight soient des nombres en récupérant leurs valeurs depuis le HTML. Nous utilisons la fonction parseInt pour nous en assurer – cette fonction de transtypage force la conversion des chaines de caractères en nombre et nous permet ainsi d’effectuer des opérations sur les variables.

Vous vous demandez peut-être également pourquoi on maintient une variable weight à jour avec le poids total de la commande alors que nous n’en faisons rien. Nous allons par la suite passer cette variable à la fonction qui calcule les frais de port. Nous pourrions simplement recharger la page et récupérer de nouveau le poids total comme nous le faisons à la ligne 23.

Néanmoins, sur Alphapole, j’ai décidé de rendre l’expérience la plus fluide possible. Aussi, tout le nécessaire est déjà chargé sur la page /panier/. Au clic sur le bouton “Passer la commande”, le JavaScript masque le tableau de commande et fait apparaître le formulaire de validation de commande (nom, prénom, adresse…), puis présente le total TTC (port inclus) avec les différentes options de paiement.

Calcul des frais de port

Vous trouverez ma fonction de calcul des frais de port sur GitHub. Il suffit de l’appeler en lui passant en paramètres le poids en grammes de la commande et la zone postale de destination. La fonction renvoie alors le prix des frais de port pour l’envoi en Colissimo.

Pour déterminer la zone géographique de l’adresse de l’utilisateur, j’utilise un select avec les différentes zones. Vous en trouverez un exemple sur le gitHub également.

Le formulaire de validation de commande ne fait pas partie du panier à proprement parler, je n’expliquerai donc pas sa mise en place. J’utilise de l’ajax pour éviter tout rechargement de page et je sauvegarde les informations de l’utilisateur dans le localStorage. De cette manière, s’il revient plus tard sur le site, il n’a pas à rentrer de nouveau toutes ses informations. Par ailleurs, je trouve cela beaucoup plus ergonomique que la création obligatoire d’un compte.

La création d’un compte n’est pas justifiée pour des sites sur lesquels on n’achète pas de manière régulière. D’accord pour Amazon, mais sur un e-commerce ou l’on ne se rend peut-être qu’une seule fois, voire une fois tous les six mois, l’internaute est découragé par la création préalable de celui-ci. Enfin, même dans la mesure où il en a déjà un, il y a de grande chance qu’il ait oublié son mot de passe comme il ne l’utilise que rarement.

Pour la mise en place des moyens de paiement, je vous invite à lire mes articles sur la mise en place et l’automatisation de Paypal avec Paypal IPN et l’API ATOS qu’utilisent les banques françaises (CIC, Société Générale…).

N’hésitez pas à me faire part de vos retours d’expérience en commentaires. Si certains passages ne sont pas assez développés ou manquent de clarté, n’hésitez pas également.

– Commentaires

Rejoignez la discussion !

Vous pouvez utiliser Markdown pour les liens [ancre de lien](url), la mise en *italique* et en **gras**. Enfin pour le code, vous pouvez utiliser la syntaxe `inline` et la syntaxe bloc

```
ceci est un bloc
de code
```