Proyecto final
Esto es todo. Componentes, props, emits, reactividad, computed, watchers, consumo de APIs, composables, routing — todo lo que has aprendido en las lecciones anteriores se une aquí en un solo proyecto. Vas a construir una aplicación completa desde cero.
Lo que vamos a construir
Un explorador de personajes de Rick and Morty usando la API pública https://rickandmortyapi.com/api. La aplicación tendrá:
- Página de inicio con bienvenida y accesos directos.
- Lista de personajes con búsqueda, paginación y estados de carga.
- Detalle de personaje con toda su información al hacer clic.
- Sistema de favoritos con persistencia en localStorage.
- Navegación entre páginas con Vue Router.
Cada paso usa conceptos que ya dominas. Si en algún momento no recuerdas algo, vuelve a la lección correspondiente — para eso están.
Paso 1 — Crear el proyecto
Crea un nuevo proyecto Vue con Router incluido:
npm create vue@latest
# Nombre: rick-morty-explorer
# TypeScript: No
# Features: selecciona Router
# Experimental features: none
# Skip example code: No
cd rick-morty-explorer
npm install
npm run dev
Limpia el contenido por defecto: vacía src/App.vue, elimina los componentes de ejemplo en src/components/ y las vistas de ejemplo en src/views/. Mantén la estructura de carpetas.
Configurar el router
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import CharactersView from '../views/CharactersView.vue'
import CharacterDetailView from '../views/CharacterDetailView.vue'
import FavoritesView from '../views/FavoritesView.vue'
const routes = [
{ path: '/', name: 'home', component: HomeView },
{ path: '/characters', name: 'characters', component: CharactersView },
{ path: '/characters/:id', name: 'character-detail', component: CharacterDetailView },
{ path: '/favorites', name: 'favorites', component: FavoritesView },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
El router importa cuatro vistas que todavía no existen. Antes de ejecutar
npm run dev, crea los archivos vacíos para que Vite no falle:
# Desde la raíz del proyecto
touch src/views/HomeView.vue
touch src/views/CharactersView.vue
touch src/views/CharacterDetailView.vue
touch src/views/FavoritesView.vue
Iremos llenando cada uno de estos archivos en los pasos siguientes.
Paso 2 — Composable useCharacters
Crea la carpeta src/composables/ y dentro el archivo useCharacters.js. Este composable encapsula toda la lógica de la API:
import { ref } from 'vue'
export function useCharacters() {
const characters = ref([])
const character = ref(null)
const loading = ref(false)
const error = ref(null)
const info = ref(null)
async function fetchCharacters(page = 1, name = '') {
loading.value = true
error.value = null
try {
let url = `https://rickandmortyapi.com/api/character?page=${page}`
if (name) url += `&name=${name}`
const response = await fetch(url)
if (!response.ok) {
if (response.status === 404) {
characters.value = []
info.value = null
return
}
throw new Error('Error al cargar los personajes')
}
const data = await response.json()
characters.value = data.results
info.value = data.info
} catch (err) {
error.value = err.message
characters.value = []
} finally {
loading.value = false
}
}
async function fetchCharacter(id) {
loading.value = true
error.value = null
try {
const response = await fetch(`https://rickandmortyapi.com/api/character/${id}`)
if (!response.ok) throw new Error('Personaje no encontrado')
character.value = await response.json()
} catch (err) {
error.value = err.message
character.value = null
} finally {
loading.value = false
}
}
return { characters, character, loading, error, info, fetchCharacters, fetchCharacter }
}
Paso 3 — Composable useFavorites
El sistema de favoritos usa localStorage para que persistan aunque el usuario cierre el navegador:
import { ref, watch } from 'vue'
const favorites = ref(JSON.parse(localStorage.getItem('rm_favorites') || '[]'))
watch(favorites, (newVal) => {
localStorage.setItem('rm_favorites', JSON.stringify(newVal))
}, { deep: true })
export function useFavorites() {
function isFavorite(id) {
return favorites.value.some(fav => fav.id === id)
}
function toggleFavorite(character) {
if (isFavorite(character.id)) {
favorites.value = favorites.value.filter(fav => fav.id !== character.id)
} else {
favorites.value.push({
id: character.id,
name: character.name,
image: character.image,
status: character.status,
species: character.species,
})
}
}
return { favorites, isFavorite, toggleFavorite }
}
Fíjate en que
favoritesestá declarado fuera de la función. Esto hace que sea un estado compartido: todos los componentes que usenuseFavorites()comparten la misma lista. Es un patrón simple de estado global sin necesidad de Pinia.
Paso 4 — Layout y navegación (App.vue)
<script setup>
import { RouterLink, RouterView } from 'vue-router'
import { useFavorites } from './composables/useFavorites.js'
const { favorites } = useFavorites()
</script>
<template>
<div class="app">
<nav class="navbar">
<RouterLink to="/" class="navbar__brand">Rick & Morty Explorer</RouterLink>
<div class="navbar__links">
<RouterLink to="/">Inicio</RouterLink>
<RouterLink to="/characters">Personajes</RouterLink>
<RouterLink to="/favorites">
Favoritos
<span v-if="favorites.length" class="badge">{{ favorites.length }}</span>
</RouterLink>
</div>
</nav>
<main class="main">
<RouterView />
</main>
</div>
</template>
<style scoped>
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background: #1a1a2e;
border-bottom: 2px solid #4FC08D;
}
.navbar__brand {
font-size: 1.25rem;
font-weight: bold;
color: #4FC08D;
text-decoration: none;
}
.navbar__links {
display: flex;
gap: 1.5rem;
}
.navbar__links a {
color: #ccc;
text-decoration: none;
transition: color 0.2s;
}
.navbar__links a:hover,
.navbar__links a.router-link-active {
color: #4FC08D;
}
.badge {
background: #4FC08D;
color: #1a1a2e;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: bold;
margin-left: 0.25rem;
}
.main {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
</style>
Paso 5 — Componente CharacterCard
<script setup>
import { RouterLink } from 'vue-router'
import { useFavorites } from '../composables/useFavorites.js'
const props = defineProps({
character: { type: Object, required: true },
})
const { isFavorite, toggleFavorite } = useFavorites()
</script>
<template>
<div class="card">
<RouterLink :to="{ name: 'character-detail', params: { id: character.id } }">
<img :src="character.image" :alt="character.name" class="card__image" />
</RouterLink>
<div class="card__body">
<h3 class="card__name">{{ character.name }}</h3>
<p class="card__info">
<span class="card__status" :class="character.status.toLowerCase()"></span>
{{ character.status }} — {{ character.species }}
</p>
<button class="card__fav" @click="toggleFavorite(character)">
{{ isFavorite(character.id) ? '❤ Quitar' : '♡ Favorito' }}
</button>
</div>
</div>
</template>
<style scoped>
.card {
background: #16213e;
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s;
}
.card:hover { transform: translateY(-4px); }
.card__image {
width: 100%;
display: block;
}
.card__body { padding: 1rem; }
.card__name {
margin: 0 0 0.5rem;
color: #fff;
font-size: 1.1rem;
}
.card__info {
color: #aaa;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card__status {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.card__status.alive { background: #4FC08D; }
.card__status.dead { background: #e74c3c; }
.card__status.unknown { background: #999; }
.card__fav {
margin-top: 0.75rem;
background: none;
border: 1px solid #4FC08D;
color: #4FC08D;
padding: 0.4rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.card__fav:hover {
background: #4FC08D;
color: #1a1a2e;
}
</style>
Paso 6 — Vista de personajes (CharactersView)
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useCharacters } from '../composables/useCharacters.js'
import CharacterCard from '../components/CharacterCard.vue'
const { characters, loading, error, info, fetchCharacters } = useCharacters()
const search = ref('')
const page = ref(1)
let debounceTimer = null
onMounted(() => fetchCharacters())
watch(search, (newVal) => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
page.value = 1
fetchCharacters(1, newVal)
}, 400)
})
function prevPage() {
if (page.value > 1) {
page.value--
fetchCharacters(page.value, search.value)
}
}
function nextPage() {
if (info.value?.next) {
page.value++
fetchCharacters(page.value, search.value)
}
}
</script>
<template>
<div>
<h1>Personajes</h1>
<input
v-model="search"
type="text"
placeholder="Buscar personaje..."
class="search-input"
/>
<div v-if="loading" class="status">Cargando personajes...</div>
<div v-else-if="error" class="status status--error">{{ error }}</div>
<div v-else-if="characters.length === 0" class="status">No se encontraron personajes.</div>
<div v-else>
<div class="grid">
<CharacterCard
v-for="character in characters"
:key="character.id"
:character="character"
/>
</div>
<div class="pagination">
<button @click="prevPage" :disabled="page <= 1">← Anterior</button>
<span>Página {{ page }} de {{ info?.pages || '?' }}</span>
<button @click="nextPage" :disabled="!info?.next">Siguiente →</button>
</div>
</div>
</div>
</template>
<style scoped>
.search-input {
width: 100%;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
border: 2px solid #333;
border-radius: 8px;
background: #16213e;
color: #fff;
font-size: 1rem;
}
.search-input:focus {
outline: none;
border-color: #4FC08D;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.5rem;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
}
.pagination button {
padding: 0.5rem 1rem;
background: #4FC08D;
color: #1a1a2e;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
}
.pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.status {
text-align: center;
color: #aaa;
padding: 3rem;
font-size: 1.1rem;
}
.status--error { color: #e74c3c; }
</style>
Paso 7 — Vista de detalle (CharacterDetailView)
<script setup>
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCharacters } from '../composables/useCharacters.js'
import { useFavorites } from '../composables/useFavorites.js'
const route = useRoute()
const router = useRouter()
const { character, loading, error, fetchCharacter } = useCharacters()
const { isFavorite, toggleFavorite } = useFavorites()
onMounted(() => fetchCharacter(route.params.id))
</script>
<template>
<div>
<button class="back-btn" @click="router.back()">← Volver</button>
<div v-if="loading" class="status">Cargando...</div>
<div v-else-if="error" class="status status--error">{{ error }}</div>
<div v-else-if="character" class="detail">
<img :src="character.image" :alt="character.name" class="detail__image" />
<div class="detail__info">
<h1>{{ character.name }}</h1>
<p>
<span class="status-dot" :class="character.status.toLowerCase()"></span>
{{ character.status }} — {{ character.species }}
</p>
<ul class="detail__list">
<li><strong>Género:</strong> {{ character.gender }}</li>
<li><strong>Origen:</strong> {{ character.origin.name }}</li>
<li><strong>Ubicación:</strong> {{ character.location.name }}</li>
<li><strong>Episodios:</strong> {{ character.episode.length }}</li>
</ul>
<button class="fav-btn" @click="toggleFavorite(character)">
{{ isFavorite(character.id) ? '❤ Quitar de favoritos' : '♡ Añadir a favoritos' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.back-btn {
background: none;
border: 1px solid #666;
color: #ccc;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
margin-bottom: 1.5rem;
}
.back-btn:hover { border-color: #4FC08D; color: #4FC08D; }
.detail {
display: flex;
gap: 2rem;
align-items: flex-start;
}
.detail__image {
width: 300px;
border-radius: 12px;
}
.detail__info h1 {
margin-top: 0;
color: #fff;
}
.detail__list {
list-style: none;
padding: 0;
}
.detail__list li {
padding: 0.4rem 0;
color: #ccc;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 0.5rem;
}
.status-dot.alive { background: #4FC08D; }
.status-dot.dead { background: #e74c3c; }
.status-dot.unknown { background: #999; }
.fav-btn {
margin-top: 1rem;
padding: 0.6rem 1.5rem;
background: none;
border: 2px solid #4FC08D;
color: #4FC08D;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s;
}
.fav-btn:hover {
background: #4FC08D;
color: #1a1a2e;
}
.status { text-align: center; color: #aaa; padding: 3rem; }
.status--error { color: #e74c3c; }
@media (max-width: 640px) {
.detail { flex-direction: column; }
.detail__image { width: 100%; }
}
</style>
Paso 8 — Favoritos e inicio
<script setup>
import { useFavorites } from '../composables/useFavorites.js'
import CharacterCard from '../components/CharacterCard.vue'
const { favorites } = useFavorites()
</script>
<template>
<div>
<h1>Tus favoritos</h1>
<div v-if="favorites.length === 0" class="empty">
<p>No tienes favoritos todavía. Explora los personajes y añade los que más te gusten.</p>
<RouterLink to="/characters" class="cta">Ver personajes</RouterLink>
</div>
<div v-else class="grid">
<CharacterCard
v-for="character in favorites"
:key="character.id"
:character="character"
/>
</div>
</div>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.5rem;
}
.empty {
text-align: center;
color: #aaa;
padding: 3rem;
}
.cta {
display: inline-block;
margin-top: 1rem;
padding: 0.6rem 1.5rem;
background: #4FC08D;
color: #1a1a2e;
border-radius: 8px;
text-decoration: none;
font-weight: bold;
}
</style>
<script setup>
import { RouterLink } from 'vue-router'
</script>
<template>
<div class="home">
<h1>Rick & Morty Explorer</h1>
<p>Explora todos los personajes del multiverso de Rick and Morty. Busca, descubre detalles y guarda tus favoritos.</p>
<div class="home__actions">
<RouterLink to="/characters" class="btn btn--primary">Explorar personajes</RouterLink>
<RouterLink to="/favorites" class="btn btn--secondary">Ver favoritos</RouterLink>
</div>
</div>
</template>
<style scoped>
.home {
text-align: center;
padding: 4rem 1rem;
}
.home h1 {
font-size: 2.5rem;
color: #4FC08D;
}
.home p {
color: #aaa;
font-size: 1.2rem;
max-width: 500px;
margin: 1rem auto 2rem;
}
.home__actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn {
padding: 0.75rem 2rem;
border-radius: 8px;
text-decoration: none;
font-weight: bold;
font-size: 1rem;
transition: opacity 0.2s;
}
.btn:hover { opacity: 0.85; }
.btn--primary {
background: #4FC08D;
color: #1a1a2e;
}
.btn--secondary {
background: transparent;
border: 2px solid #4FC08D;
color: #4FC08D;
}
</style>
Lo que has construido
Mira todo lo que acabas de hacer. En una sola aplicación has usado:
- Componentes reutilizables (
CharacterCard) con props. - Composables (
useCharacters,useFavorites) para encapsular lógica. - Reactividad con
ref()ywatch(). - Consumo de API con
fetchyasync/awaitenonMounted. - Renderizado condicional con
v-if/v-elsepara estados de carga, error y vacío. - Listas dinámicas con
v-fory:key. - Routing con Vue Router: rutas, parámetros dinámicos, navegación programática.
- Persistencia en localStorage para los favoritos.
- Eventos y binding dinámico de clases y atributos.
Has construido una SPA completa y funcional. Esto ya no es un ejercicio de curso — es una aplicación real que podrías enseñar en una entrevista de trabajo.
Extiende el explorador del multiverso
Tu explorador funciona, pero siempre se puede mejorar. Elige al menos dos de estas extensiones y añádelas al proyecto:
- Filtro por estado: añade botones o un select para filtrar por Alive, Dead o Unknown (usa el parámetro
?status=de la API). - Filtro por especie: añade un filtro por especie (Human, Alien, etc.) usando
?species=. - Lista de episodios: en la vista de detalle, haz fetch de los episodios del personaje y muestra sus nombres.
- Personaje aleatorio: añade un botón en la home que navegue a un personaje al azar (la API tiene 826 personajes).
- Transiciones de ruta: envuelve el
<RouterView>con<Transition>para animar el cambio de página.
lightbulb Pistas
Para el filtro por estado, puedes añadir un ref para el status seleccionado y pasarlo a fetchCharacters. Para el personaje aleatorio, genera un número entre 1 y 826 con Math.ceil(Math.random() * 826) y usa router.push. Para las transiciones, consulta la documentación de <Transition> con <RouterView> usando el slot v-slot.