Skip to content

Étape 16 — Interface favoris (carte + page)

Objectifs

  • Ajouter un bouton coeur sur chaque PokemonCard pour gérer les favoris
  • Comprendre le piège @click.stop.prevent (propagation et comportement par défaut)
  • Créer une page /favoris qui réutilise PokemonCard
  • Afficher un message si l'utilisateur n'a aucun favori

Résultat attendu

Cartes avec bouton favori

Page favoris vide

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 :

ComposantRôleDocumentation
<v-card-actions>Zone d'actions en bas de la carteCards
<v-btn>Bouton avec icôneButtons
<v-spacer>Pousse le bouton à droiteGrids

Dans le <script setup>, importez le store et instanciez-le :

js
import { usePokemonStore } from '@/stores/pokemonStore'

const pokemonStore = usePokemonStore()

Ajoutez le bouton dans le template, après <v-card-text> :

vue
<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 :

  • :icon change dynamiquement entre coeur plein et coeur vide
  • :color passe en rouge quand le Pokémon est favori
  • variant="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.

ComposantRôleDocumentation
<v-snackbar>Message temporaire en bas de l'écranSnackbars

Dans PokemonCard.vue, ajoutez deux variables réactives :

js
const showSnackbar = ref(false)
const snackbarMessage = ref('')

Créez une fonction qui gère le toggle et affiche le snackbar :

js
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 :

vue
@click.stop.prevent="handleToggleFavorite()"

Ajoutez le v-snackbar dans le template, juste avant la fermeture de </v-card> :

vue
<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 :

ModificateurMéthode JS équivalenteRôle
.stopevent.stopPropagation()Empêche l'événement de remonter aux éléments parents
.preventevent.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 :

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
  • getFavorites retourne les objets Pokémon complets (pas juste les IDs)
  • v-if / v-else affiche soit l'alerte, soit la grille — jamais les deux
  • Le titre affiche le compteur totalFavorites entre 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 :

js
{
  path: '/favoris',
  component: () => import('@/pages/favoris.vue'),
},

Ajoutez un lien dans votre menu de navigation (par exemple dans AppHeader.vue) :

js
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 :

  1. Ajoutez un bouton favori sur vos cartes/éléments
  2. Utilisez @click.stop.prevent si le parent est cliquable
  3. Créez une page dédiée aux favoris
  4. Affichez un message quand la liste est vide

Références utiles

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 /favoris affiche les Pokémon marqués comme favoris
  • Retirer un favori depuis la page /favoris retire 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
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
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

bash
git add -A
git commit -m "feat: bouton favori sur PokemonCard et page favoris"

Documentation pour les cours de développement web