◆ 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.

index.html · 14.0 Ko

<!DOCTYPE html>
<!--
  ==========================================================================
  index.html — Mini-boutique de produits locaux d'Antalaha
  Tutoriel Alpine.js, niveau INTERMÉDIAIRE
  --------------------------------------------------------------------------
  Ce que fait la page : afficher un catalogue, le rechercher, le filtrer, le
  trier, et gérer un panier (ajout, quantités, total) qui est sauvegardé.

  Fichiers :
    data/produits.js  -> les données (variable globale PRODUITS)
    js/boutique.js    -> la logique (fonction boutique())
    css/style.css     -> l'apparence

  Lisez le HTML en parallèle de js/boutique.js : chaque nom utilisé ici
  (recherche, produitsAffiches, ajouter()…) y est défini.
  ==========================================================================
-->
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Boutique d'Antalaha</title>
  <link rel="stylesheet" href="css/style.css">

  <!--
    ORDRE DES SCRIPTS — très important :
      1. produits.js : crée PRODUITS et CATEGORIES ;
      2. boutique.js : définit boutique() qui utilise PRODUITS ;
      3. alpine.min.js : démarre en dernier et lit x-data="boutique()".
    Tous sont « defer » : ils sont exécutés dans l'ordre d'écriture, une
    fois la page lue. Si on inversait, Alpine chercherait boutique()
    avant qu'elle n'existe et afficherait une erreur dans la console.
  -->
  <script src="data/produits.js" defer></script>
  <script src="js/boutique.js" defer></script>
  <script src="/libs/alpine.min.js" defer></script>
</head>
<body>

<!--
  SPRITE SVG : une « boîte à icônes » invisible. Chaque <symbol> a un id ;
  plus bas on les affiche avec <use href="#ic-xxx">. Avantage : on écrit le
  dessin une seule fois, et il prend la couleur du texte (currentColor).
  (Une icône chargée avec <img> serait toujours noire.)
-->
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <symbol id="ic-vanille" viewBox="0 0 24 24"><path d="M5 3c8 2 12 8 14 18M7 3c6 3 9 8 10 15M4 5l3-2M17 21l3-2"/></symbol>
  <symbol id="ic-palmier" viewBox="0 0 24 24"><path d="M12 21c0-5 1-9 3-13M15 8c-3-4-7-3-9 0M15 8c1-4 5-5 8-3M15 8c-1-3-1-6 1-7M15 8c3-1 5 1 5 4"/></symbol>
  <symbol id="ic-soleil" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/></symbol>
  <symbol id="ic-feuille" viewBox="0 0 24 24"><path d="M5 19C5 10 11 5 20 4c0 9-5 15-14 15zM5 19l8-8"/></symbol>
  <symbol id="ic-panier" viewBox="0 0 24 24"><path d="M5 8h14l-1.5 12h-11zM9 8V6a3 3 0 0 1 6 0v2"/></symbol>
  <symbol id="ic-recherche" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></symbol>
  <symbol id="ic-fermer" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></symbol>
</svg>

<!--
  x-data="boutique()" : tout le contenu de ce <div> partage le même état.
  Le panier (<aside>) est DANS ce <div> : il voit donc les mêmes données
  que le catalogue. C'est la règle : un enfant accède aux données du parent.

  @keydown.escape.window="panierOuvert = false" :
    - @keydown : écoute les touches ;
    - .escape : seulement la touche Échap ;
    - .window : écoute sur toute la fenêtre (pas seulement sur cet élément).
-->
<div x-data="boutique()" @keydown.escape.window="panierOuvert = false">

  <header class="entete">
    <div class="entete-contenu">
      <div>
        <h1>Boutique d'Antalaha</h1>
        <p>Produits locaux : épices, vannerie, huiles et douceurs.</p>
      </div>
      <!-- Bouton du panier. x-text affiche le nombre d'articles. -->
      <button type="button" class="btn-panier" @click="panierOuvert = true"
              :aria-label="'Ouvrir le panier, ' + nombreArticles + ' article(s)'">
        <svg class="ic" aria-hidden="true"><use href="#ic-panier"></use></svg>
        <span class="pastille" x-text="nombreArticles"></span>
      </button>
    </div>
  </header>

  <!-- x-show : l'avertissement n'apparaît que si localStorage est bloqué.
       x-cloak : caché jusqu'à ce qu'Alpine soit prêt (évite un flash). -->
  <p class="avertissement" x-show="memoireSeule" x-cloak role="status">
    Sauvegarde durable indisponible dans ce navigateur : votre panier sera perdu si vous rechargez la page.
  </p>

  <main class="contenu">

    <!-- ===================== OUTILS : recherche, filtres, tri ===================== -->
    <section class="outils" aria-label="Rechercher et filtrer">
      <div class="champ-recherche">
        <label for="recherche">Rechercher un produit</label>
        <div class="rangee">
          <!--
            x-ref="recherche" : donne un « nom » à cet élément. Dans le
            JavaScript, on le retrouve avec this.$refs.recherche (voir la
            méthode effacerRecherche).
            x-model="recherche" : chaque lettre tapée met à jour la
            propriété, et produitsAffiches est recalculé aussitôt.
          -->
          <input id="recherche" type="search" x-ref="recherche" x-model="recherche"
                 placeholder="vanille, panier, huile…" autocomplete="off">
          <button type="button" class="secondaire" @click="effacerRecherche()"
                  x-show="recherche !== '' || categorie !== 'toutes'" x-cloak>
            Effacer
          </button>
        </div>
      </div>

      <div class="champ-tri">
        <label for="tri">Trier par</label>
        <select id="tri" x-model="tri">
          <option value="nom">Nom (A à Z)</option>
          <option value="prix-asc">Prix croissant</option>
          <option value="prix-desc">Prix décroissant</option>
          <option value="note">Meilleures notes</option>
        </select>
      </div>

      <!-- Filtres de catégorie : des boutons créés par x-for -->
      <div class="filtres" role="group" aria-label="Catégories">
        <!--
          :class avec un objet { 'nomDeClasse': condition } : la classe
          « actif » n'est présente que si la condition est vraie.
          :aria-pressed indique aux lecteurs d'écran le bouton choisi.
        -->
        <button type="button" class="filtre" @click="categorie = 'toutes'"
                :class="{ 'actif': categorie === 'toutes' }" :aria-pressed="categorie === 'toutes'">
          Toutes
        </button>
        <!-- x-for sur un <template> : un bouton par catégorie.
             :key doit être unique pour chaque élément répété. -->
        <template x-for="c in categories" :key="c.id">
          <button type="button" class="filtre" @click="categorie = c.id"
                  :class="{ 'actif': categorie === c.id }" :aria-pressed="categorie === c.id"
                  x-text="c.nom"></button>
        </template>
      </div>
    </section>

    <!-- Résultat de la recherche, annoncé aux lecteurs d'écran -->
    <p class="compteur" aria-live="polite">
      <span x-text="produitsAffiches.length"></span>
      produit<span x-show="produitsAffiches.length > 1">s</span> affiché<span x-show="produitsAffiches.length > 1">s</span>
    </p>

    <!--
      x-if vs x-show :
        - x-show cache avec CSS (l'élément existe toujours) ;
        - x-if retire/crée vraiment l'élément ; il DOIT être sur un
          <template> avec UN seul enfant.
      Ici, x-if affiche un message quand la recherche ne trouve rien.
    -->
    <template x-if="produitsAffiches.length === 0">
      <div class="vide">
        <p><strong>Aucun produit ne correspond.</strong></p>
        <p>Essayez un autre mot, ou <button type="button" class="lien" @click="effacerRecherche()">effacez la recherche</button>.</p>
      </div>
    </template>

    <!-- ===================== CATALOGUE ===================== -->
    <ul class="grille">
      <!--
        x-for="p in produitsAffiches" : une carte par produit filtré/trié.
        :key="p.id" : l'identifiant unique permet à Alpine de déplacer les
        cartes existantes quand l'ordre change, au lieu de tout recréer.
        Sans :key (ou avec l'index), on obtient des bugs d'affichage.
      -->
      <template x-for="p in produitsAffiches" :key="p.id">
        <li class="carte-produit">
          <!-- :style avec un objet : définit une variable CSS par produit -->
          <div class="vignette" :style="{ background: p.teinte }">
            <!-- :href dynamique : choisit l'icône du sprite selon le produit -->
            <svg class="ic grande" aria-hidden="true"><use :href="'#ic-' + p.icone"></use></svg>
            <!-- Étiquette « Rupture » seulement si stock = 0 -->
            <span class="etiquette" x-show="p.stock === 0">Rupture</span>
          </div>
          <div class="corps">
            <h2 x-text="p.nom"></h2>
            <p class="note" :aria-label="'Note ' + p.note + ' sur 5'" x-text="etoiles(p.note)"></p>
            <p class="description" x-text="p.description"></p>
            <p class="prix">
              <strong x-text="ariary(p.prix)"></strong>
              <small x-text="' / ' + p.unite"></small>
            </p>
            <!--
              :disabled : le bouton est désactivé si le stock est épuisé
              ou si tout le stock est déjà dans le panier.
              @click="ajouter(p.id)" : on passe l'id du produit courant (p)
              à la méthode. Dans x-for, la variable « p » est disponible
              pour tous les attributs de la carte.
            -->
            <button type="button" class="principal"
                    :disabled="p.stock === 0 || quantiteDans(p.id) >= p.stock"
                    @click="ajouter(p.id)"
                    :aria-label="'Ajouter ' + p.nom + ' au panier'">
              <span x-text="p.stock === 0 ? 'Indisponible' : (quantiteDans(p.id) >= p.stock ? 'Stock atteint' : 'Ajouter au panier')"></span>
            </button>
            <p class="deja" x-show="quantiteDans(p.id) > 0" x-text="quantiteDans(p.id) + ' dans le panier'"></p>
          </div>
        </li>
      </template>
    </ul>
  </main>

  <!-- ===================== MESSAGE TEMPORAIRE (toast) ===================== -->
  <!--
    x-transition : anime l'apparition/disparition provoquée par x-show.
    Le modificateur « .opacity » = simple fondu ; « .duration.200ms » = durée.
    role="status" : lu poliment par les lecteurs d'écran.
  -->
  <div class="toast" role="status" x-show="messageVisible" x-cloak
       x-transition.opacity.duration.200ms x-text="message"></div>

  <!-- ===================== TIROIR DU PANIER ===================== -->
  <!-- Fond sombre : cliquer dessus ferme le panier. Transition en fondu. -->
  <div class="fond" x-show="panierOuvert" x-cloak @click="panierOuvert = false"
       x-transition.opacity></div>

  <!--
    Transition en classes personnalisées (définies dans style.css) :
      x-transition:enter        classes pendant toute l'entrée (durée, courbe)
      x-transition:enter-start  état de DÉPART  (hors écran)
      x-transition:enter-end    état d'ARRIVÉE (à sa place)
    Idem avec « leave » pour la sortie.
    role="dialog" + aria-label : décrit un panneau modal pour l'accessibilité.
  -->
  <aside class="panier" role="dialog" aria-label="Votre panier"
         x-show="panierOuvert" x-cloak
         x-transition:enter="glisse-entree"
         x-transition:enter-start="glisse-hors"
         x-transition:enter-end="glisse-dans"
         x-transition:leave="glisse-entree"
         x-transition:leave-start="glisse-dans"
         x-transition:leave-end="glisse-hors">

    <div class="panier-tete">
      <h2>Mon panier</h2>
      <button type="button" class="icone-btn" @click="panierOuvert = false" aria-label="Fermer le panier">
        <svg class="ic" aria-hidden="true"><use href="#ic-fermer"></use></svg>
      </button>
    </div>

    <!-- Cas 1 : commande validée (x-if car on crée/supprime vraiment le bloc) -->
    <template x-if="commandeValidee">
      <div class="merci">
        <p><strong>Merci pour votre commande !</strong></p>
        <p>Ceci est une simulation : rien n'a été envoyé.</p>
        <button type="button" class="secondaire" @click="panierOuvert = false">Continuer mes achats</button>
      </div>
    </template>

    <!-- Cas 2 : panier vide -->
    <template x-if="!commandeValidee && panier.length === 0">
      <div class="merci">
        <p>Votre panier est vide.</p>
        <button type="button" class="secondaire" @click="panierOuvert = false">Voir les produits</button>
      </div>
    </template>

    <!-- Cas 3 : il y a des lignes -->
    <template x-if="panier.length > 0">
      <div class="panier-corps">
        <ul class="lignes">
          <!-- On boucle sur le getter « lignes » (voir boutique.js) -->
          <template x-for="l in lignes" :key="l.id">
            <li class="ligne">
              <div class="ligne-info">
                <strong x-text="l.produit.nom"></strong>
                <small x-text="ariary(l.produit.prix) + ' / ' + l.produit.unite"></small>
              </div>
              <div class="quantite">
                <button type="button" class="rond" @click="changerQuantite(l.id, -1)"
                        :aria-label="'Une unité de moins pour ' + l.produit.nom">−</button>
                <span x-text="l.qte" aria-live="polite"></span>
                <button type="button" class="rond" @click="changerQuantite(l.id, 1)"
                        :disabled="l.qte >= l.produit.stock"
                        :aria-label="'Une unité de plus pour ' + l.produit.nom">+</button>
              </div>
              <div class="ligne-prix">
                <strong x-text="ariary(l.sousTotal)"></strong>
                <button type="button" class="lien" @click="retirer(l.id)"
                        :aria-label="'Retirer ' + l.produit.nom">Retirer</button>
              </div>
            </li>
          </template>
        </ul>

        <div class="pied">
          <p class="total"><span>Total</span> <strong x-text="ariary(total)"></strong></p>
          <button type="button" class="principal" @click="commander()">Valider la commande</button>
          <button type="button" class="lien" @click="vider()">Vider le panier</button>
        </div>
      </div>
    </template>
  </aside>

</div>

</body>
</html>