En esta página
Proyecto final — Gestor de contactos
Proyecto final: Gestor de contactos
En esta lección final construirás un gestor de contactos completo que integra todos los conceptos del curso:
- Pinia store — gestión de contactos con filtrado y favoritos
- Vue Router — rutas para lista, detalle, crear y editar
- Composables —
useFormContactopara la lógica del formulario - defineModel() — componente de input reutilizable
- TransitionGroup — lista animada de contactos
- Validación de formularios — con mensajes de error accesibles
- Sistema de notificaciones — toast con Pinia y TransitionGroup
Arquitectura del proyecto
src/
├── components/
│ ├── TarjetaContacto.vue # Tarjeta de contacto con acciones
│ ├── FormContacto.vue # Formulario reutilizable (crear/editar)
│ └── InputCampo.vue # Input con label y error integrados
├── composables/
│ ├── useFormContacto.ts # Lógica de formulario y validación
│ └── useConfirmar.ts # Dialog de confirmación
├── stores/
│ ├── contactos.ts # Store principal de contactos
│ └── notificaciones.ts # Store de toasts
├── views/
│ ├── ListaView.vue # Lista con filtros y búsqueda
│ ├── DetalleView.vue # Vista de detalle de contacto
│ ├── CrearView.vue # Formulario de creación
│ └── EditarView.vue # Formulario de edición
└── router/index.ts # Configuración de rutasStore de notificaciones
// stores/notificaciones.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
interface Notificacion {
id: string
mensaje: string
tipo: 'exito' | 'error' | 'info'
}
export const useNotificacionesStore = defineStore('notificaciones', () => {
const lista = ref<Notificacion[]>([])
function push(mensaje: string, tipo: Notificacion['tipo'] = 'info'): void {
const id = crypto.randomUUID()
lista.value.push({ id, mensaje, tipo })
// Auto-eliminar después de 3 segundos
setTimeout(() => {
lista.value = lista.value.filter(n => n.id !== id)
}, 3000)
}
return { lista, push }
})Composable useFormContacto
// composables/useFormContacto.ts
import { reactive, computed } from 'vue'
import type { Contacto } from '@/stores/contactos'
type DatosForm = Omit<Contacto, 'id' | 'fechaCreacion'>
export function useFormContacto(inicial?: Partial<DatosForm>) {
const form = reactive<DatosForm>({
nombre: inicial?.nombre ?? '',
email: inicial?.email ?? '',
telefono: inicial?.telefono ?? '',
empresa: inicial?.empresa ?? '',
favorito: inicial?.favorito ?? false,
})
const errores = reactive<Partial<Record<keyof DatosForm, string>>>({})
function validarCampo(campo: keyof DatosForm): boolean {
switch (campo) {
case 'nombre':
errores.nombre = form.nombre.trim().length >= 2
? undefined
: 'El nombre debe tener al menos 2 caracteres'
break
case 'email':
errores.email = !form.email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)
? undefined
: 'Ingresa un email válido'
break
}
return !errores[campo]
}
function validarTodo(): boolean {
return (['nombre', 'email'] as const).every(validarCampo)
}
const esValido = computed(() =>
form.nombre.trim().length >= 2 &&
(!form.email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
)
return { form, errores, validarCampo, validarTodo, esValido }
}Componente TarjetaContacto
<!-- components/TarjetaContacto.vue -->
<script setup lang="ts">
import type { Contacto } from '@/stores/contactos'
import { useRouter } from 'vue-router'
const { contacto } = defineProps<{ contacto: Contacto }>()
const emit = defineEmits<{
toggleFavorito: []
eliminar: []
}>()
const router = useRouter()
const iniciales = contacto.nombre
.split(' ')
.slice(0, 2)
.map(p => p[0]?.toUpperCase() ?? '')
.join('')
</script>
<template>
<article class="tarjeta-contacto" :class="{ favorito: contacto.favorito }">
<div class="avatar" aria-hidden="true">{{ iniciales }}</div>
<div class="info">
<h3>{{ contacto.nombre }}</h3>
<p v-if="contacto.empresa">{{ contacto.empresa }}</p>
<a v-if="contacto.email" :href="`mailto:${contacto.email}`">
{{ contacto.email }}
</a>
</div>
<div class="acciones">
<button
type="button"
:aria-label="contacto.favorito ? 'Quitar de favoritos' : 'Añadir a favoritos'"
:aria-pressed="contacto.favorito"
@click="emit('toggleFavorito')"
>
{{ contacto.favorito ? '★' : '☆' }}
</button>
<button
type="button"
aria-label="Editar contacto"
@click="router.push(`/editar/${contacto.id}`)"
>
✏️
</button>
<button
type="button"
aria-label="Eliminar contacto"
@click="emit('eliminar')"
>
🗑️
</button>
</div>
</article>
</template>Vista de creación con el formulario
<!-- views/CrearView.vue -->
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useContactosStore } from '@/stores/contactos'
import { useNotificacionesStore } from '@/stores/notificaciones'
import { useFormContacto } from '@/composables/useFormContacto'
import FormContacto from '@/components/FormContacto.vue'
const router = useRouter()
const store = useContactosStore()
const notif = useNotificacionesStore()
const { form, errores, validarCampo, validarTodo, esValido } = useFormContacto()
function guardar(): void {
if (!validarTodo()) return
store.agregarContacto({ ...form })
notif.push(`¡Contacto "${form.nombre}" creado exitosamente!`, 'exito')
router.push('/')
}
</script>
<template>
<div class="crear-view">
<h2>Nuevo contacto</h2>
<FormContacto
:form="form"
:errores="errores"
:es-valido="esValido"
@validar-campo="validarCampo"
@guardar="guardar"
@cancelar="router.push('/')"
/>
</div>
</template>Vista de edición con parámetro de ruta
<!-- views/EditarView.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useContactosStore } from '@/stores/contactos'
import { useNotificacionesStore } from '@/stores/notificaciones'
import { useFormContacto } from '@/composables/useFormContacto'
import FormContacto from '@/components/FormContacto.vue'
const route = useRoute()
const router = useRouter()
const store = useContactosStore()
const notif = useNotificacionesStore()
const id = route.params['id'] as string
const contacto = store.obtenerPorId(id)
if (!contacto) {
router.replace('/')
notif.push('Contacto no encontrado', 'error')
}
const { form, errores, validarCampo, validarTodo, esValido } =
useFormContacto(contacto)
function guardar(): void {
if (!validarTodo()) return
store.actualizarContacto(id, { ...form })
notif.push(`Contacto "${form.nombre}" actualizado`, 'exito')
router.push('/')
}
</script>
<template>
<div class="editar-view">
<h2>Editar contacto</h2>
<FormContacto
:form="form"
:errores="errores"
:es-valido="esValido"
@validar-campo="validarCampo"
@guardar="guardar"
@cancelar="router.push('/')"
/>
</div>
</template>Configuración del router
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('@/views/ListaView.vue'),
},
{
path: '/nuevo',
component: () => import('@/views/CrearView.vue'),
},
{
path: '/editar/:id',
component: () => import('@/views/EditarView.vue'),
},
{
path: '/contacto/:id',
component: () => import('@/views/DetalleView.vue'),
},
],
})
export default routerEstilos y animaciones
/* Transición de página */
.page-enter-active,
.page-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.page-enter-from {
opacity: 0;
transform: translateX(20px);
}
.page-leave-to {
opacity: 0;
transform: translateX(-20px);
}
/* Lista de contactos */
.lista-enter-from,
.lista-leave-to {
opacity: 0;
transform: scale(0.9);
}
.lista-enter-active,
.lista-leave-active {
transition: all 0.3s ease;
}
.lista-move {
transition: transform 0.3s ease;
}
/* Toasts */
.toast-container {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 9999;
}
.toast-enter-from { transform: translateX(100%); opacity: 0; }
.toast-leave-to { transform: translateX(100%); opacity: 0; }
.toast-enter-active,
.toast-leave-active { transition: all 0.3s ease; }
.toast { padding: 0.75rem 1rem; border-radius: 0.5rem; color: white; }
.toast.exito { background: #22c55e; }
.toast.error { background: #ef4444; }
.toast.info { background: #3b82f6; }Ejercicios de extensión
Una vez completado el gestor básico, puedes extenderlo con:
- Grupos/etiquetas: Añade un campo
grupos: string[]al contacto y un filtro por grupo en la lista - Importar/exportar: Botones para exportar los contactos como JSON y para importar desde un archivo
- Búsqueda con debounce: Envuelve el filtro de búsqueda con un composable
useDebouncepara no filtrar en cada tecla - Persistencia: Usa
pinia-plugin-persistedstatepara que los contactos sobrevivan al recargar la página - Avatar con iniciales coloreado: Genera un color de fondo único basado en el nombre del contacto usando una función hash
Felicidades — has completado Vue.js Esencial
A lo largo de este curso has aprendido:
- Fundamentos — SFC, template syntax, directivas
- Reactividad — ref(), reactive(), computed(), watch()
- Componentes — Props, emits, defineModel(), slots
- Composables — Lógica reutilizable con la Composition API
- Inyección — provide() e inject() para contexto en árbol
- Enrutamiento — Vue Router 4 con lazy loading y guards
- Estado global — Pinia con Setup Stores y DevTools
- Formularios — v-model profundo y validación
- Datos async — useFetch, Suspense, manejo de errores
- Animaciones — Transition, TransitionGroup, hooks JS
- Testing — Vitest + Vue Test Utils, stores, composables
El siguiente paso natural es explorar Nuxt.js para aplicaciones con SSR y generación estática, o profundizar en patrones avanzados de Vue como render functions, plugins y directivas personalizadas.
crypto.randomUUID() — IDs únicos sin librerías
crypto.randomUUID() es una API nativa del navegador (y Node.js 15+) que genera UUIDs v4 sin necesidad de instalar librerías como uuid. Está disponible en todos los navegadores modernos y es la forma recomendada de generar IDs únicos en el cliente.
Proyecto completo — todos los conceptos integrados
Este proyecto integra todos los conceptos del curso: SFC con script setup, Pinia para estado global, Vue Router con rutas con parámetros, composables para lógica reutilizable, v-model en componentes con defineModel(), TransitionGroup para listas animadas y validación de formularios.
Persistencia — los datos se pierden al recargar
El gestor de contactos usa estado en memoria. Para persistir los datos entre sesiones, añade pinia-plugin-persistedstate al store de contactos con persist: true. En producción usarías una API REST o Supabase como backend.
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { useNotificacionesStore } from '@/stores/notificaciones'
import { storeToRefs } from 'pinia'
import { TransitionGroup } from 'vue'
const notificaciones = useNotificacionesStore()
const { lista } = storeToRefs(notificaciones)
</script>
<template>
<div class="app-layout">
<header class="app-header">
<h1>📒 Contactos</h1>
<nav>
<RouterLink to="/">Lista</RouterLink>
<RouterLink to="/nuevo">+ Nuevo</RouterLink>
</nav>
</header>
<main class="app-main">
<RouterView v-slot="{ Component }">
<Transition name="page" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main>
<!-- Sistema de notificaciones toast -->
<div class="toast-container" aria-live="polite">
<TransitionGroup name="toast">
<div
v-for="n in lista"
:key="n.id"
class="toast"
:class="n.tipo"
role="status"
>
{{ n.mensaje }}
</div>
</TransitionGroup>
</div>
</div>
</template>
Inicia sesión para guardar tu progreso