◆ Portail Antalaha

Annuaire › Code ouvert

Mini-boutique de produits locaux (Alpine.js)

tutorial · Voir le site ↗
Connectez-vous pour copier ce site

Code publié par son auteur, en lecture seule. « Copier dans mon espace » crée un nouveau site à vous (non publié) que vous pouvez modifier librement.

js/boutique.js · 9.4 Ko

/* ==========================================================================
   js/boutique.js — La logique de la mini-boutique
   --------------------------------------------------------------------------
   Définit la fonction globale boutique(), utilisée par  x-data="boutique()"
   dans index.html. Elle retourne l'objet « composant » d'Alpine.

   Dépend de data/produits.js (PRODUITS, CATEGORIES) : ce fichier doit donc
   être chargé AVANT celui-ci.

   Contenu :
     1. un petit « stockage » sûr (localStorage avec repli en mémoire) ;
     2. la fonction boutique() : état, getters, méthodes, init().
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. STOCKAGE SÛR
   PIÈGE du bac à sable : ce site a une origine « opaque ». Rien que LIRE
   window.localStorage lève une SecurityError. On enveloppe donc tout dans
   try/catch et, si ça échoue, on garde les données dans une simple variable
   (elles seront perdues quand on recharge la page : c'est le « repli »).
   -------------------------------------------------------------------------- */
const stockage = (function () {
  const memoire = {};          // repli : un objet JavaScript ordinaire
  let utiliseMemoire = false;  // passe à true dès que localStorage échoue

  return {
    /** Lit une valeur (texte) ou null. */
    lire(cle) {
      try {
        return window.localStorage.getItem(cle);
      } catch (e) {
        utiliseMemoire = true;
        return Object.prototype.hasOwnProperty.call(memoire, cle) ? memoire[cle] : null;
      }
    },
    /** Écrit une valeur (texte). */
    ecrire(cle, valeur) {
      try {
        window.localStorage.setItem(cle, valeur);
      } catch (e) {
        utiliseMemoire = true;
        memoire[cle] = valeur;
      }
    },
    /** Vrai si on est obligé de rester en mémoire (donc pas de sauvegarde durable). */
    get enMemoire() { return utiliseMemoire; }
  };
})();

const CLE_PANIER = 'boutique-antalaha-panier';

/* --------------------------------------------------------------------------
   2. LE COMPOSANT
   -------------------------------------------------------------------------- */
function boutique() {
  return {
    /* ----- ÉTAT ----- */
    produits: PRODUITS,        // le catalogue (tableau, venant de data/produits.js)
    categories: CATEGORIES,
    recherche: '',             // texte tapé dans le champ de recherche
    categorie: 'toutes',       // 'toutes' ou un id de catégorie
    tri: 'nom',                // 'nom' | 'prix-asc' | 'prix-desc' | 'note'
    panier: [],                // liste de { id, qte }
    panierOuvert: false,       // le tiroir du panier est-il visible ?
    commandeValidee: false,    // affiche le message de remerciement
    message: '',               // texte du petit message temporaire (toast)
    messageVisible: false,
    _minuteur: null,           // identifiant du setTimeout du message
    memoireSeule: false,       // true si la sauvegarde durable est impossible

    /* ----- init() : appelée AUTOMATIQUEMENT par Alpine au démarrage ----- */
    init() {
      this.charger();

      /* $watch('propriété', fonction) : exécute la fonction à chaque fois
         que la propriété change. Ici : on sauvegarde le panier dès qu'il
         est modifié, sans avoir à y penser dans chaque méthode.
         (La surveillance est « profonde » : changer une quantité à
         l'intérieur d'une ligne déclenche aussi la fonction.) */
      this.$watch('panier', () => this.sauvegarder());

      /* Autre $watch : quand le tiroir s'ouvre, on remet à zéro le message
         de commande validée pour repartir proprement. */
      this.$watch('panierOuvert', (ouvert) => {
        if (ouvert && this.panier.length > 0) this.commandeValidee = false;
      });
    },

    /* ----- PERSISTANCE ----- */

    /** Relit le panier sauvegardé et le « nettoie » (données non fiables). */
    charger() {
      try {
        const brut = stockage.lire(CLE_PANIER);
        const liste = brut ? JSON.parse(brut) : [];
        // On ne garde que les lignes valides : produit connu, quantité entière >= 1.
        this.panier = Array.isArray(liste)
          ? liste
              .filter(l => l && this.trouver(l.id) && Number.isInteger(l.qte) && l.qte > 0)
              .map(l => ({ id: l.id, qte: Math.min(l.qte, this.trouver(l.id).stock) }))
              .filter(l => l.qte > 0)
          : [];
      } catch (e) {
        this.panier = [];     // JSON abîmé : on repart d'un panier vide
      }
      this.memoireSeule = stockage.enMemoire;
    },

    /** Écrit le panier (en texte JSON). */
    sauvegarder() {
      stockage.ecrire(CLE_PANIER, JSON.stringify(this.panier));
      this.memoireSeule = stockage.enMemoire;
    },

    /* ----- GETTERS (valeurs calculées) ----- */

    /** Produits après recherche + filtre de catégorie + tri. */
    get produitsAffiches() {
      // Normalise : minuscules et sans accents, pour que « epice » trouve « Épices ».
      const norm = (t) => t.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
      const q = norm(this.recherche.trim());

      // filter() garde les produits pour lesquels la fonction retourne true.
      let liste = this.produits.filter(p =>
        (this.categorie === 'toutes' || p.categorie === this.categorie) &&
        (q === '' || norm(p.nom + ' ' + p.description).includes(q))
      );

      // sort() modifie le tableau : comme filter() en a déjà créé un NOUVEAU,
      // on ne touche pas à la liste d'origine.
      if (this.tri === 'prix-asc') liste.sort((a, b) => a.prix - b.prix);
      else if (this.tri === 'prix-desc') liste.sort((a, b) => b.prix - a.prix);
      else if (this.tri === 'note') liste.sort((a, b) => b.note - a.note || a.nom.localeCompare(b.nom, 'fr'));
      else liste.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'));
      return liste;
    },

    /** Lignes du panier enrichies du produit complet et du sous-total. */
    get lignes() {
      return this.panier.map(l => {
        const produit = this.trouver(l.id);
        return { id: l.id, qte: l.qte, produit, sousTotal: produit.prix * l.qte };
      });
    },

    /** Nombre total d'articles (somme des quantités). */
    get nombreArticles() {
      // reduce() cumule : on part de 0 et on ajoute chaque quantité.
      return this.panier.reduce((somme, l) => somme + l.qte, 0);
    },

    /** Total du panier en Ariary. */
    get total() {
      return this.lignes.reduce((somme, l) => somme + l.sousTotal, 0);
    },

    /* ----- MÉTHODES ----- */

    /** Retrouve un produit par son id (ou undefined). */
    trouver(id) {
      return this.produits.find(p => p.id === id);
    },

    /** Quantité déjà dans le panier pour ce produit (0 si absent). */
    quantiteDans(id) {
      const ligne = this.panier.find(l => l.id === id);
      return ligne ? ligne.qte : 0;
    },

    /** Formate un montant : 45000 -> « 45 000 Ar ». */
    ariary(n) {
      return new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 }).format(n) + ' Ar';
    },

    /** Ajoute un produit au panier (sans dépasser le stock). */
    ajouter(id) {
      const produit = this.trouver(id);
      if (!produit || produit.stock === 0) return;
      const ligne = this.panier.find(l => l.id === id);
      if (ligne) {
        if (ligne.qte >= produit.stock) {
          this.annoncer('Stock maximum atteint pour « ' + produit.nom + ' ».');
          return;
        }
        ligne.qte++;     // Alpine détecte la modification : $watch se déclenche
      } else {
        this.panier.push({ id: id, qte: 1 });
      }
      this.commandeValidee = false;
      this.annoncer('« ' + produit.nom + ' » ajouté au panier.');
    },

    /** Change la quantité d'une ligne de +1 ou -1 ; à 0, la ligne disparaît. */
    changerQuantite(id, delta) {
      const ligne = this.panier.find(l => l.id === id);
      if (!ligne) return;
      const stock = this.trouver(id).stock;
      const nouvelle = ligne.qte + delta;
      if (nouvelle <= 0) { this.retirer(id); return; }
      if (nouvelle > stock) { this.annoncer('Stock maximum atteint.'); return; }
      ligne.qte = nouvelle;
    },

    /** Supprime complètement une ligne. */
    retirer(id) {
      // filter() recrée le tableau sans la ligne concernée.
      this.panier = this.panier.filter(l => l.id !== id);
    },

    /** Vide le panier. */
    vider() {
      this.panier = [];
    },

    /** « Valide » la commande (simulation : rien n'est envoyé). */
    commander() {
      if (this.panier.length === 0) return;
      this.panier = [];
      this.commandeValidee = true;
    },

    /** Efface la recherche et remet le curseur dans le champ. */
    effacerRecherche() {
      this.recherche = '';
      this.categorie = 'toutes';
      /* this.$refs.recherche : accès à l'élément portant x-ref="recherche".
         On attend $nextTick pour que le DOM soit à jour avant de focaliser. */
      this.$nextTick(() => this.$refs.recherche.focus());
    },

    /** Affiche un message temporaire (disparaît après 2,5 s). */
    annoncer(texte) {
      this.message = texte;
      this.messageVisible = true;
      clearTimeout(this._minuteur);           // annule le minuteur précédent
      this._minuteur = setTimeout(() => { this.messageVisible = false; }, 2500);
    },

    /** Étoiles de note, en texte : 4 -> « ★★★★☆ ». */
    etoiles(n) {
      return '★'.repeat(n) + '☆'.repeat(5 - n);
    }
  };
}