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.
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);
}
};
}