Annuaire › Code ouvert
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>