Étape 16 — Interface favoris (carte + page)
Objectifs
- Ajouter un bouton coeur sur chaque
PokemonCardpour gérer les favoris - Comprendre le piège
@click.stop.prevent(propagation et comportement par défaut) - Créer une page
/favorisqui réutilisePokemonCard - Afficher un message si l'utilisateur n'a aucun favori
Résultat attendu


Contexte
Le store gère les favoris depuis l'étape 15, mais l'interface ne permet pas encore de les utiliser. On va ajouter un bouton coeur sur chaque carte et créer une page dédiée aux favoris.
Tâches
1. Ajouter le bouton favori dans PokemonCard.vue
Dans src/components/PokemonCard.vue, ajoutez une section <v-card-actions> en bas de la carte, avec un bouton coeur :
| Composant | Rôle | Documentation |
|---|---|---|
<v-card-actions> | Zone d'actions en bas de la carte | Cards |
<v-btn> | Bouton avec icône | Buttons |
<v-spacer> | Pousse le bouton à droite | Grids |
Dans le <script setup>, importez le store et instanciez-le :
import { usePokemonStore } from '@/stores/pokemonStore'
const pokemonStore = usePokemonStore()Ajoutez le bouton dans le template, après <v-card-text> :
<v-card-actions>
<v-spacer />
<v-btn
:icon="pokemonStore.isFavorite(pokemon) ? 'mdi-heart' : 'mdi-heart-outline'"
:color="pokemonStore.isFavorite(pokemon) ? 'red' : ''"
variant="text"
@click.stop.prevent="pokemonStore.toggleFavorite(pokemon)"
/>
</v-card-actions>Points importants :
:iconchange dynamiquement entre coeur plein et coeur vide:colorpasse en rouge quand le Pokémon est favorivariant="text"rend le bouton transparent (pas de fond)
2. Ajouter un snackbar de confirmation
Quand l'utilisateur clique sur le coeur, un message de confirmation doit apparaître brièvement en bas de l'écran. C'est le composant v-snackbar de Vuetify.
| Composant | Rôle | Documentation |
|---|---|---|
<v-snackbar> | Message temporaire en bas de l'écran | Snackbars |
Dans PokemonCard.vue, ajoutez deux variables réactives :
const showSnackbar = ref(false)
const snackbarMessage = ref('')Créez une fonction qui gère le toggle et affiche le snackbar :
function handleToggleFavorite() {
const wasFavorite = pokemonStore.isFavorite(pokemon)
pokemonStore.toggleFavorite(pokemon)
snackbarMessage.value = wasFavorite ? 'Retiré des favoris' : 'Ajouté aux favoris'
showSnackbar.value = true
}Remplacez l'appel direct au store dans le @click du bouton coeur :
@click.stop.prevent="handleToggleFavorite()"Ajoutez le v-snackbar dans le template, juste avant la fermeture de </v-card> :
<v-snackbar
v-model="showSnackbar"
:timeout="2000"
color="primary"
>
{{ snackbarMessage }}
</v-snackbar>Encart oral — Pourquoi dans PokemonCard ?
On pourrait mettre le snackbar dans la page parente, mais ça compliquerait la communication (il faudrait un emit). En le plaçant dans PokemonCard, le composant est autonome : il gère à la fois l'action et le feedback.
3. Comprendre @click.stop.prevent
Le piège classique — sans .stop.prevent
Si vous écrivez simplement @click="pokemonStore.toggleFavorite(pokemon)", cliquer sur le coeur navigue vers la page de détail au lieu de toggler le favori.
Pourquoi ? Parce que <v-card> a un attribut :to qui en fait un lien (comme <router-link>). Le clic sur le bouton remonte jusqu'à la carte et déclenche la navigation.
Encart oral — .stop vs .prevent
Vue.js propose des modificateurs d'événement qu'on ajoute après @click :
| Modificateur | Méthode JS équivalente | Rôle |
|---|---|---|
.stop | event.stopPropagation() | Empêche l'événement de remonter aux éléments parents |
.prevent | event.preventDefault() | Empêche le comportement par défaut de l'élément |
Dans notre cas, on a besoin des deux :
.stop→ empêche le clic de remonter jusqu'à<v-card>(qui déclencherait la navigation).prevent→ empêche le comportement par défaut du<router-link>sous-jacent
┌─────────────────────────────────┐
│ <v-card :to="/pokemon/123"> │ ← .prevent empêche la navigation
│ ┌───────────────────────────┐ │
│ │ <v-btn @click> │ │ ← .stop empêche la remontée
│ │ ❤️ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘Sans .stop : le clic remonte à <v-card>, qui déclenche la navigation. Sans .prevent : le <router-link> suit le lien malgré le .stop. Avec les deux : seul toggleFavorite() s'exécute.
4. Créer la page favoris
Créez le fichier src/pages/favoris.vue :
<template>
<v-container>
<h1 class="text-h3 text-center my-6">
Mes favoris ({{ pokemonStore.totalFavorites }})
</h1>
<!-- Message si aucun favori -->
<v-alert
v-if="pokemonStore.totalFavorites === 0"
type="info"
variant="tonal"
class="mb-6"
>
Vous n'avez pas encore de Pokémon favoris.
Cliquez sur le coeur d'un Pokémon pour l'ajouter !
</v-alert>
<!-- Grille de cartes Pokémon favoris -->
<v-row v-else>
<v-col
v-for="pokemon in pokemonStore.getFavorites"
:key="pokemon.id"
cols="12"
sm="6"
md="4"
lg="3"
>
<pokemon-card :pokemon="pokemon" />
</v-col>
</v-row>
</v-container>
</template>
<script setup>
import { usePokemonStore } from '@/stores/pokemonStore'
import PokemonCard from '@/components/PokemonCard.vue'
const pokemonStore = usePokemonStore()
</script>Points importants :
- On réutilise
PokemonCard— le bouton coeur fonctionne aussi sur cette page getFavoritesretourne les objets Pokémon complets (pas juste les IDs)v-if/v-elseaffiche soit l'alerte, soit la grille — jamais les deux- Le titre affiche le compteur
totalFavoritesentre parenthèses
5. Ajouter la route et le lien de navigation
Si votre projet utilise le file-based routing (dossier src/pages/), la route /favoris est créée automatiquement.
Sinon, ajoutez la route dans src/router/index.js :
{
path: '/favoris',
component: () => import('@/pages/favoris.vue'),
},Ajoutez un lien dans votre menu de navigation (par exemple dans AppHeader.vue) :
const menuItems = [
{ title: 'Accueil', path: '/', icon: 'mdi-pokeball' },
{ title: 'Favoris', path: '/favoris', icon: 'mdi-heart' },
]Schéma — Interaction favori dans PokemonCard
Projet perso — À faire en parallèle
Dans votre projet individuel :
- Ajoutez un bouton favori sur vos cartes/éléments
- Utilisez
@click.stop.preventsi le parent est cliquable - Créez une page dédiée aux favoris
- Affichez un message quand la liste est vide
Références utiles
- Vue.js — Modificateurs d'événement
- MDN — Event.stopPropagation()
- MDN — Event.preventDefault()
- Vuetify — Cards
- Vuetify — Buttons
- Vuetify — Alerts
- Vuetify — Snackbars
Tests
- Le bouton coeur s'affiche sur chaque carte Pokémon
- Cliquer sur le coeur ne navigue pas vers la page de détail
- Cliquer sur le coeur change l'icône (vide → plein) et la couleur (gris → rouge)
- Cliquer à nouveau retire le favori (plein → vide)
- La page
/favorisaffiche les Pokémon marqués comme favoris - Retirer un favori depuis la page
/favorisretire la carte immédiatement - Si aucun favori, le message d'alerte s'affiche
- Recharger la page : les favoris sont toujours là
- Le compteur dans le titre se met à jour en temps réel
- Un snackbar « Ajouté aux favoris » apparaît quand on ajoute un favori
- Un snackbar « Retiré des favoris » apparaît quand on retire un favori
- Le snackbar disparaît automatiquement après 2 secondes
- 0 erreurs dans la console
Solution
src/components/PokemonCard.vue
<template>
<!--
Carte d'un Pokémon
* :to crée un lien vers la page de détail du Pokémon
* hover ajoute un effet d'élévation au survol
-->
<v-card
class="pokemon-card"
:to="`/pokemon/${pokemon.id}`"
hover
>
<!--
Image du Pokémon
* height="200" fixe la hauteur de l'image
* cover remplit l'espace disponible en gardant les proportions
-->
<v-img
:src="getImageUrl(pokemon.img)"
:alt="pokemon.name"
height="200"
cover
>
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular
indeterminate
color="grey-lighten-4"
/>
</div>
</template>
</v-img>
<!-- Nom du Pokémon -->
<v-card-title>{{ pokemon.name }}</v-card-title>
<!-- Niveau du Pokémon -->
<v-card-subtitle>Niveau {{ pokemon.level }}</v-card-subtitle>
<!--
Actions de la carte
* v-spacer pousse le bouton à droite
* Le bouton coeur toggle le favori
-->
<v-card-actions>
<v-spacer />
<!--
Bouton pour ajouter/retirer des favoris
* @click.stop.prevent empêche la navigation vers la page de détail
* .stop arrête la propagation de l'événement (ne remonte pas à v-card)
* .prevent empêche le comportement par défaut du router-link
* L'icône change selon l'état favori (coeur plein ou vide)
* La couleur change : rouge si favori, grise sinon
-->
<v-btn
:icon="pokemonStore.isFavorite(pokemon) ? 'mdi-heart' : 'mdi-heart-outline'"
:color="pokemonStore.isFavorite(pokemon) ? 'red' : ''"
variant="text"
@click.stop.prevent="handleToggleFavorite()"
/>
</v-card-actions>
<!--
Snackbar de confirmation
* v-model contrôle l'affichage
* timeout="2000" masque automatiquement après 2 secondes
* Le message change selon l'action (ajout ou retrait)
-->
<v-snackbar
v-model="showSnackbar"
:timeout="2000"
color="primary"
>
{{ snackbarMessage }}
</v-snackbar>
</v-card>
</template>
<script setup>
import { getImageUrl } from '@/utils/imageUrl'
import { usePokemonStore } from '@/stores/pokemonStore'
/**
* Props du composant PokemonCard
* @property {Object} pokemon - Objet Pokémon avec id, name, level, img, etc.
*/
const { pokemon } = defineProps({
pokemon: {
type: Object,
required: true,
},
})
const pokemonStore = usePokemonStore()
// Snackbar de confirmation
const showSnackbar = ref(false)
const snackbarMessage = ref('')
/**
* Toggle le favori et affiche un snackbar de confirmation
*/
function handleToggleFavorite() {
const wasFavorite = pokemonStore.isFavorite(pokemon)
pokemonStore.toggleFavorite(pokemon)
snackbarMessage.value = wasFavorite ? 'Retiré des favoris' : 'Ajouté aux favoris'
showSnackbar.value = true
}
</script>src/pages/favoris.vue
<template>
<v-container>
<h1 class="text-h3 text-center my-6">
Mes favoris ({{ pokemonStore.totalFavorites }})
</h1>
<!-- Message si aucun favori -->
<v-alert
v-if="pokemonStore.totalFavorites === 0"
type="info"
variant="tonal"
class="mb-6"
>
Vous n'avez pas encore de Pokémon favoris.
Cliquez sur le coeur d'un Pokémon pour l'ajouter !
</v-alert>
<!-- Grille de cartes Pokémon favoris -->
<v-row v-else>
<v-col
v-for="pokemon in pokemonStore.getFavorites"
:key="pokemon.id"
cols="12"
sm="6"
md="4"
lg="3"
>
<pokemon-card :pokemon="pokemon" />
</v-col>
</v-row>
</v-container>
</template>
<script setup>
import { usePokemonStore } from '@/stores/pokemonStore'
import PokemonCard from '@/components/PokemonCard.vue'
const pokemonStore = usePokemonStore()
</script>Commit
git add -A
git commit -m "feat: bouton favori sur PokemonCard et page favoris"