En esta página

Proyecto final — Gestor de contactos

25 min lectura TextoCap. 5 — Producción

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
  • ComposablesuseFormContacto para 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 rutas

Store 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 router

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

  1. Grupos/etiquetas: Añade un campo grupos: string[] al contacto y un filtro por grupo en la lista
  2. Importar/exportar: Botones para exportar los contactos como JSON y para importar desde un archivo
  3. Búsqueda con debounce: Envuelve el filtro de búsqueda con un composable useDebounce para no filtrar en cada tecla
  4. Persistencia: Usa pinia-plugin-persistedstate para que los contactos sobrevivan al recargar la página
  5. 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>