Como funciona el proyecto
Verticals Next Generation Front
Section titled “Verticals Next Generation Front”🎯 Introducción
Section titled “🎯 Introducción”Esta guía está diseñada para ayudar al equipo a entender y trabajar eficientemente en el proyecto Verticals Next Generation Front de Alebat Education. El proyecto es una aplicación multi-vertical que permite gestionar diferentes plataformas educativas desde una única base de código.
🚀 Cómo Iniciar el Proyecto
Section titled “🚀 Cómo Iniciar el Proyecto”📋 Requisitos Previos
Section titled “📋 Requisitos Previos”- Node.js v18 o superior
- pnpm (gestor de paquetes)
- Git
⚡ Instalación
Section titled “⚡ Instalación”- Clonar el repositorio:
git clone https://github.com/Alebat-Education/verticals-next-generation-front.gitcd verticals-next-generation-front- Instalar dependencias:
pnpm install- Configurar variables de entorno:
🌐 Sistema de Puertos por Vertical
Section titled “🌐 Sistema de Puertos por Vertical”Cada vertical está configurado para funcionar en un puerto específico, permitiendo el desarrollo independiente o conjunto:
Puertos Asignados:
Section titled “Puertos Asignados:”El orden de los puertos puede cambiar según las necesidades, pero la asignación actual es:
- Inspiria: Puerto 3000 (predeterminado)
- Salud Mental: Puerto 3001
- Traumatología: Puerto 3002
- Emergencias: Puerto 3003
- Oncología: Puerto 3004
- Pharma: Puerto 3005
¿Por qué esta estructura?
Section titled “¿Por qué esta estructura?”Este sistema permite:
- Desarrollo paralelo - Diferentes equipos pueden trabajar en verticales distintos simultáneamente
- Testing independiente - Probar cambios en un vertical sin afectar otros
- Debugging específico - Identificar problemas en verticales concretos
- Optimización de recursos - Solo ejecutar los verticales necesarios durante el desarrollo
🎯 Comandos Disponibles
Section titled “🎯 Comandos Disponibles”Desarrollo Individual
Section titled “Desarrollo Individual”# Ejecutar solo Inspiria en puerto 3000pnpm dev
# Ejecutar con apertura automática del navegadorpnpm dev -oDesarrollo Multi-Vertical
Section titled “Desarrollo Multi-Vertical”# Ejecutar "Salud Mental" (puerto 3001)pnpm run dev -- --port 3001
# Ejecutar "Traumatología" (puerto 3002)pnpm run dev -- --port 3002Otros Comandos Esenciales
Section titled “Otros Comandos Esenciales”# Build y Deploypnpm build # Construir para producciónpnpm generate # Generar sitio estáticopnpm preview # Vista previa de build
# Testing y Calidadpnpm test # Ejecutar testspnpm vitest # Ejecutar tests una vezpnpm lint # Verificar códigopnpm lint:fix # Corregir errores automáticamentepnpm styles # Verificar estilos SCSSpnpm styles:fix # Corregir estilos
# Utilidadespnpm format # Formatear código con Prettierpnpm analyze # Análisis de tipos TypeScriptpnpm clean # Limpiar cache de Nuxt🌍 URLs de Desarrollo y Producción
Section titled “🌍 URLs de Desarrollo y Producción”Desarrollo Local
Section titled “Desarrollo Local”- Base:
http://localhost:3000(puerto variable según vertical)
Producción por Vertical
Section titled “Producción por Vertical”- Inspiria:
https://pre.inspiriadental.com/ - Traumatología:
https://traumatologia.alebateducation.com/ - Salud Mental:
https://saludmental.alebateducation.com/ - Emergencias:
https://emergencias.alebateducation.com/ - Oncología:
https://oncologia.alebateducation.com/ - Pharma:
https://pharma.alebateducation.com/
🛠️ Configuración del Entorno de Desarrollo
Section titled “🛠️ Configuración del Entorno de Desarrollo”Linters Obligatorios
Section titled “Linters Obligatorios”⚠️ IMPORTANTE: Los siguientes linters DEBEN estar instalados y configurados en Visual Studio Code:
// .vscode/extensions.json (recomendado){ "recommendations": [ "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "stylelint.vscode-stylelint", "Vue.volar" ]}¿Por qué son obligatorios?
- ESLint: Detecta errores de código, magic strings, variables no utilizadas
- Prettier: Mantiene formato consistente de código
- Stylelint: Verifica reglas BEM, uso de variables CSS, unidades px prohibidas
- Volar: Soporte completo para Vue 3 + TypeScript
Configuración VS Code Recomendada
Section titled “Configuración VS Code Recomendada”{ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "eslint.validate": ["vue", "javascript", "typescript"], "stylelint.validate": ["css", "scss", "vue"]}Sin estos linters configurados, experimentarás:
- ❌ Errores de indentación constantes
- ❌ Magic strings no detectados
- ❌ Variables CSS incorrectas
- ❌ Problemas de compilación TypeScript
✅ Mejores Prácticas
Section titled “✅ Mejores Prácticas”� Reglas Críticas - Cumplimiento Obligatorio
Section titled “� Reglas Críticas - Cumplimiento Obligatorio”¿Por qué estas reglas?
Section titled “¿Por qué estas reglas?”Estas reglas no son arbitrarias - están diseñadas para prevenir errores costosos en producción, mantener la consistencia del código y facilitar el mantenimiento a largo plazo. Su incumplimiento puede causar bugs, problemas de rendimiento y dificultades para otros desarrolladores.
Magic Strings y Magic Numbers - ¿Por qué son peligrosos?
Section titled “Magic Strings y Magic Numbers - ¿Por qué son peligrosos?”❌ Problema:
// Imagina que 'admin' se usa en 15 archivos diferentes, 10 veces en cada unoif (user.role === "admin") { // Lógica administrativa}
// En otro archivo:if (user.role === "admin") { // Más lógica}
// Y en 148 lugares más...💥 ¿Qué pasa cuando necesitas cambiar ‘admin’ por ‘administrator’?
- ❌ Debes encontrar y reemplazar 150 ocurrencias manualmente
- ❌ Riesgo de olvidar algunas y crear bugs
- ❌ No hay autocompleta ni verificación de tipos
- ❌ Errores silenciosos si escribes mal el string
✅ Solución:
export const USER_ROLES = { ADMIN: "admin", USER: "user", MODERATOR: "moderator",} as const;
// Uso en cualquier archivo:if (user.role === USER_ROLES.ADMIN) { // Lógica administrativa}🎯 Beneficios:
- ✅ Un solo lugar para cambiar el valor (1 línea vs 150)
- ✅ Autocompleta de TypeScript
- ✅ Errores en tiempo de compilación si escribes mal
- ✅ Refactoring seguro con herramientas IDE
- ✅ Documentación implícita de valores permitidos
Código TypeScript
Section titled “Código TypeScript”✅ Hacer
Section titled “✅ Hacer”// 1. Usar tipos explícitos para mejor documentación y seguridadinterface UserData { id: number; name: string; email: string; role: (typeof USER_ROLES)[keyof typeof USER_ROLES];}
// 2. Constantes tipadas - evitan magic stringsconst USER_ROLES = { ADMIN: "admin", USER: "user",} as const;
// 3. Props con tipos específicos - mejor IntelliSense y detección de erroresinterface Props { title: string; variant?: "primary" | "secondary" | "danger"; isDisabled?: boolean;}const props = withDefaults(defineProps<Props>(), { variant: "primary", isDisabled: false,});
// 4. Principios SOLID aplicadosclass UserService { constructor( private validator: UserValidator, // Dependency Injection private repository: UserRepository, // Single Responsibility private logger: Logger ) {}
async createUser(userData: UserData): Promise<User> { this.validator.validate(userData); // Open/Closed Principle const user = await this.repository.save(userData); this.logger.log(`User ${user.id} created`); return user; }}🔗 Recursos para profundizar:
❌ Evitar - Errores Críticos
Section titled “❌ Evitar - Errores Críticos”// ❌ Magic strings - valor hardcodeado sin constanteif (user.role === "admin") {} // ¿De dónde sale 'admin'? ¿Es el único valor válido?
// ❌ Tipos any - pierdes toda la verificación de TypeScriptconst data: any = await fetch(); // ¿Qué propiedades tiene? ¿Cómo lo uso?
// ❌ Variables sin uso - código muerto que confundeimport { ref, computed, watch } from "vue"; // ← 'watch' importado pero no usado
// ❌ var - comportamiento impredecible con hoisting y scopevar userName = "Juan"; // Usar const o let
// ❌ console.log en producción - información sensible expuestaconsole.log("User data:", userData); // Eliminar antes de producción🎯 ¿Por qué evitar estos patrones?
- Magic strings: Sin autocompleta, errores tipográficos, difícil refactoring
- Tipos
any: Pierdes verificación de tipos, errores en runtime - Variables sin uso: Código confuso, bundles más grandes
var: Scope impredecible, puede causar bugs sutilesconsole.log: Expone información en producción, afecta rendimiento
Componentes Vue
Section titled “Componentes Vue”✅ Hacer
Section titled “✅ Hacer”<script setup lang="ts">// 1. Interfaces claras para props - documentación automáticainterface Props { items: string[]; isLoading?: boolean; variant?: "compact" | "expanded";}
const props = withDefaults(defineProps<Props>(), { isLoading: false, variant: "compact",});
// 2. Composables para lógica reutilizable - Single Responsibilityconst { isVisible, open, close } = useModal();const { data, isPending, error } = useApiData<UserData>("users");
// 3. Cleanup apropiado - evita memory leaksonUnmounted(() => { close(); // Cerrar modales clearInterval(intervalId); // Limpiar timers removeEventListener("resize", handler); // Quitar listeners});
// 4. Constantes para iconos - evita SVG inlineimport { ICONS } from "~/constants/icons";</script>
<template> <div class="component"> <!-- ✅ Usar componente Icon - se optimiza automáticamente --> <Icon :name="ICONS.action.loading" v-if="isLoading" />
<!-- ✅ Estructura semántica con BEM --> <div class="component__content" v-else> <h2 class="component__title">{{ props.title }}</h2> <slot /> </div> </div></template>
<style lang="scss" scoped>.component { // ✅ Variables CSS del sistema de diseño padding: var(--s-spacing-medium); background-color: var(--c-background-primary);
&__content { @include flex( column, flex-start, flex-start, nowrap, var(--s-spacing-small) ); }
&__title { font-family: var(--f-font-semiBold); font-size: var(--s-font-h3); color: var(--c-text-primary); }}</style>🔗 Recursos para profundizar:
❌ Evitar - Errores Críticos
Section titled “❌ Evitar - Errores Críticos”<template> <!-- ❌ Comentarios HTML - se incluyen en el bundle --> <!-- TODO: mejorar este componente -->
<!-- ❌ SVG inline - no se optimiza, código repetitivo --> <svg><path d="M4.5 12.75l6 6 9-13.5"/></svg>
<!-- ❌ Magic strings en clases - no sigue BEM --> <div class="red-button big-text">
<!-- ❌ Textos hardcodeados - no son traducibles --> <h1>Bienvenido a nuestra plataforma</h1></template>
<script setup lang="ts">// ❌ Imports innecesarios - Vue se autoimportaimport { ref } from 'vue'
// ❌ Props sin tipos - no hay verificaciónconst props = defineProps(['title', 'items'])
// ❌ Console.log - información expuestaconsole.log('Component mounted')
// ❌ Sin cleanup - memory leaks potencialesconst timer = setInterval(() => { // lógica repetitiva}, 1000) // ¿Se limpia al desmontar?</script>🎨 Estilos SCSS - Sistema de Diseño
Section titled “🎨 Estilos SCSS - Sistema de Diseño”🚨 REGLAS CRÍTICAS - OBLIGATORIO CUMPLIR
Section titled “🚨 REGLAS CRÍTICAS - OBLIGATORIO CUMPLIR”Prohibido Usar Unidades Píxeles (px)
Section titled “Prohibido Usar Unidades Píxeles (px)”❌ ¿Por qué están prohibidos los px?
Los píxeles son unidades fijas que no se adaptan a:
- ✅ Preferencias de accesibilidad del usuario
- ✅ Diferentes densidades de pantalla
- ✅ Escalado del sistema operativo
- ✅ Responsive design fluido
// ❌ PROHIBIDO - Usar px directamente.componente { font-size: 24px; // ¡ERROR! No es accesible margin: 10px; // ¡ERROR! No es escalable padding: 16px 20px; // ¡ERROR! No se adapta border-radius: 8px; // ¡ERROR! Valor mágico}
// ✅ CORRECTO - Usar variables CSS predefinidas.componente { font-size: var(--s-font-h3); // Se adapta a preferencias usuario margin: var(--s-spacing-small); // Consistente en todo el proyecto padding: var(--s-spacing-medium) var(--s-spacing-large); // Escalable border-radius: var(--s-border-radius-card); // Sistemático}🔗 Recursos esenciales:
Variables CSS Obligatorias
Section titled “Variables CSS Obligatorias”TODAS las propiedades deben usar las variables predefinidas del sistema de diseño:
// ✅ Variables de Color (--c-*).component { color: var(--c-text-primary); // Color de texto principal background: var(--c-primary); // Color primario del vertical border: 1px solid var(--c-grey-medium); // Borde gris estándar}
// ✅ Variables de Tipografía (--f-*).title { font-family: var(--f-font-bold); // Fuente en negrita font-size: var(--s-font-h1); // Tamaño título principal}
// ✅ Variables de Espaciado (--s-*).card { padding: var(--s-margin-blocks) var(--s-padding-lateral); // Espaciado de bloque margin: 0 0 var(--s-spacing-medium) 0; // Margen inferior medio border-radius: var(--s-border-radius-card); // Radio bordes card}
// ✅ Variables de Transición (--t-*).button { transition: all var(--t-transition-button); // Transición estándar}Mixins Obligatorios
Section titled “Mixins Obligatorios”SIEMPRE usar los mixins predefinidos para evitar repetición:
// 🔧 Mixins Disponibles
// 1. Responsive Design - en lugar de @media queries@include responsive($size: 64rem) { // Estilos para pantallas menores a 64rem}
// 2. Flexbox - en lugar de display: flex manual@include flex( $direction: row, $align: center, $justify: center, $wrap: nowrap, $gap: 0);
// Ejemplos prácticos:.hero { @include flex(column, center, center, nowrap, var(--s-spacing-large));
@include responsive() { @include flex( column, flex-start, flex-start, nowrap, var(--s-spacing-small) ); }}
// 3. Estados de carga.loading-card { &--skeleton { @include skeleton(); // Efecto shimmer }}🔗 Mixins documentados:
- Ubicación:
/app/assets/styles/mixin.scss - SCSS Guidelines
✅ Hacer - Buenas Prácticas
Section titled “✅ Hacer - Buenas Prácticas”.product-grid { // 1. Usar variables CSS para todo background-color: var(--c-background-primary); padding: var(--s-padding-lateral);
// 2. Mixins para layouts repetitivos @include flex(row, flex-start, flex-start, wrap, var(--s-spacing-medium));
// 3. Responsive con mixin @include responsive() { @include flex(column, center, center, nowrap, var(--s-spacing-small)); padding: var(--s-padding-lateral-mobile); }
// 4. BEM obligatorio - bloque__elemento--modificador &__item { background-color: var(--c-white); border-radius: var(--s-border-radius-card);
// 5. Propiedades abreviadas obligatorias margin: 0 0 var(--s-spacing-medium) 0; // top right bottom left padding: var(--s-spacing-small) var(--s-spacing-medium); // vertical horizontal
&--featured { background-color: var(--c-primary);
// 6. Transiciones con variables transition: transform var(--t-transition-button);
&:hover { transform: translateY(-4px); } } }}❌ Evitar - Errores Críticos
Section titled “❌ Evitar - Errores Críticos”// ❌ CRÍTICO: Usar px directamente.component { font-size: 16px; // ¡ERROR! Sin accesibilidad margin: 10px 20px; // ¡ERROR! Magic numbers border-radius: 8px; // ¡ERROR! No sistemático}
// ❌ CRÍTICO: Colores hardcodeados.button { color: #333333; // ¡ERROR! No está en el sistema background: #ffffff; // ¡ERROR! Inconsistente border: 1px solid red; // ¡ERROR! Magic color}
// ❌ CRÍTICO: Propiedades individuales de spacing.card { margin-top: 16px; // ¡ERROR! Usar margin: 16px 0 0 0; margin-bottom: 20px; // ¡ERROR! Usar margin: 0 0 20px 0; padding-left: 10px; // ¡ERROR! Usar padding: 0 0 0 10px;}
// ❌ CRÍTICO: Sin BEM.component .title { font-size: 24px; // ¡ERROR! Anidación incorrecta + px}
// ❌ CRÍTICO: CSS repetitivo sin mixins.header { display: flex; // ¡ERROR! Usar @include flex() flex-direction: column; align-items: center; justify-content: center;}
// ❌ CRÍTICO: Media queries sin mixin@media (max-width: 768px) { // ¡ERROR! Usar @include responsive() .component { font-size: 14px; // ¡ERROR! px + magic number }}📱 Diferencia entre Composables y Utils
Section titled “📱 Diferencia entre Composables y Utils”Es fundamental entender cuándo usar cada uno para mantener la arquitectura del proyecto:
🎛️ Composables (/app/composables/)
Section titled “🎛️ Composables (/app/composables/)”¿Qué son? Funciones que usan la reactividad de Vue y el ciclo de vida de componentes.
¿Cuándo usar?
- ✅ Necesitas reactividad (
ref,reactive,computed) - ✅ Usas lifecycle hooks (
onMounted,onUnmounted) - ✅ Compartes estado entre componentes
- ✅ Manejas efectos secundarios en componentes
// ✅ useModal.ts - COMPOSABLE correctoexport const useModal = () => { const isVisible = ref(false); // ← Reactividad
const open = () => { isVisible.value = true; };
const close = () => { isVisible.value = false; };
// ← Lifecycle hook onUnmounted(() => { close(); // Cleanup automático });
return { isVisible: readonly(isVisible), // ← Estado reactivo open, close, };};
// ✅ useApiData.ts - COMPOSABLE correctoexport const useApiData = <T>(endpoint: string) => { const data = ref<T | null>(null); const isLoading = ref(false); const error = ref<string | null>(null);
const fetchData = async () => { isLoading.value = true; try { const response = await httpClient.get<T>({ resource: endpoint }); data.value = response.data; } catch (err) { error.value = "Error al cargar datos"; } finally { isLoading.value = false; } };
onMounted(() => { fetchData(); // ← Se ejecuta en el ciclo de vida });
return { data: readonly(data), isLoading: readonly(isLoading), error: readonly(error), refetch: fetchData, };};🔧 Utils (/app/utils/)
Section titled “🔧 Utils (/app/utils/)”¿Qué son? Funciones puras que transforman datos sin efectos secundarios.
¿Cuándo usar?
- ✅ Transformaciones de datos puros
- ✅ Validaciones que no necesitan reactividad
- ✅ Cálculos matemáticos o lógicos
- ✅ Funciones que se usan en server-side y client-side
// ✅ dateFormats.ts - UTIL correctoexport class DateUtils { static formatToSpanish(date: Date): string { return date.toLocaleDateString("es-ES", { year: "numeric", month: "long", day: "numeric", }); }
static isWeekend(date: Date): boolean { const day = date.getDay(); return day === 0 || day === 6; }
static daysBetween(date1: Date, date2: Date): number { const msPerDay = 24 * 60 * 60 * 1000; return Math.round(Math.abs((date1.getTime() - date2.getTime()) / msPerDay)); }}
// ✅ validator.ts - UTIL correctoexport const REGEX = { email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, phone: /^\d{6,10}$/, password: /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,}$/,};
export class Validator { static validateEmail(email: string): boolean { return REGEX.email.test(email); }
static validatePassword(password: string): { isValid: boolean; errors: string[]; } { const errors: string[] = [];
if (password.length < 8) { errors.push("Mínimo 8 caracteres"); } if (!/[A-Z]/.test(password)) { errors.push("Al menos una mayúscula"); } if (!/[a-z]/.test(password)) { errors.push("Al menos una minúscula"); } if (!/\d/.test(password)) { errors.push("Al menos un número"); }
return { isValid: errors.length === 0, errors, }; }}🚫 Errores Comunes
Section titled “🚫 Errores Comunes”// ❌ ERROR: Usar ref en utilsexport const createCounter = () => { const count = ref(0); // ¡ERROR! ref es para composables return count;};
// ❌ ERROR: Lógica pura en composables// composables/badExample.tsexport const useStringUtils = () => { const capitalize = (str: string) => { // ¡ERROR! Es función pura return str.charAt(0).toUpperCase() + str.slice(1); };
return { capitalize };};
// ✅ CORRECTO: Mover a utils// utils/stringUtils.tsexport const capitalize = (str: string): string => { return str.charAt(0).toUpperCase() + str.slice(1);};📝 Regla Simple
Section titled “📝 Regla Simple”- ¿Necesita reactividad o lifecycle? →
composables/ - ¿Es función pura sin efectos secundarios? →
utils/
🛠️ Stack Tecnológico
Section titled “🛠️ Stack Tecnológico”Framework Principal
Section titled “Framework Principal”- Nuxt 4 + Vue 3 con Composition API
- TypeScript para tipado estático
- SCSS para estilos
- SPA Mode (SSR deshabilitado)
Dependencias Clave
Section titled “Dependencias Clave”- @pinia/nuxt - Gestión de estado
- @nuxtjs/i18n - Internacionalización
- @nuxt/image - Optimización de imágenes
- @nuxt/icon - Manejo de iconos
- @nuxtjs/strapi - Integración con CMS Strapi v5
- Vitest - Testing framework
- HLS.js - Streaming de video
- Plyr - Reproductor multimedia
- Swiper - Carruseles y sliders
Herramientas de Desarrollo
Section titled “Herramientas de Desarrollo”- ESLint - Linting de código
- Prettier - Formateo de código
- Stylelint - Linting de estilos
- Husky - Git hooks
- pnpm - Gestor de paquetes
🏗️ Arquitectura del Proyecto
Section titled “🏗️ Arquitectura del Proyecto”El proyecto utiliza una arquitectura multi-vertical donde:
- Base común: Funcionalidades compartidas entre verticales
- Configuración específica: Cada vertical tiene su configuración única
- Servicios modulares: APIs específicas para cada vertical
- Layouts dinámicos: Layouts que se adaptan según el vertical activo
Principios Arquitectónicos
Section titled “Principios Arquitectónicos”- Single Responsibility Principle (SRP): Cada componente/servicio tiene una responsabilidad única
- Dependency Injection: Los servicios se inyectan dinámicamente
- Modularidad: Código organizado en módulos reutilizables
- Type Safety: Uso extensivo de TypeScript para prevenir errores
📁 Estructura del Proyecto - Explicación Detallada
Section titled “📁 Estructura del Proyecto - Explicación Detallada”🗂️ Visión General
Section titled “🗂️ Visión General”El proyecto se organiza de manera intuitiva donde cada carpeta tiene un propósito específico. Esta estructura permite:
- ✅ Encontrar archivos rápidamente sin buscar en múltiples lugares
- ✅ Mantener el código organizado por funcionalidad
- ✅ Escalar el proyecto sin que se vuelva inmanejable
- ✅ Trabajar en equipo sin conflictos de archivos
app/├── assets/ # 🎨 Recursos estáticos (imágenes, estilos)├── components/ # 🧩 Componentes Vue reutilizables├── composables/ # 🎛️ Funciones reactivas de Vue├── config/ # ⚙️ Configuraciones del proyecto├── constants/ # 📋 Valores constantes y configuración├── core/ # 🔧 Funcionalidades centrales del sistema├── directives/ # 📝 Directivas personalizadas de Vue├── interfaces/ # 📄 Definiciones de tipos TypeScript├── layouts/ # 📐 Layouts de página├── locales/ # 🌍 Archivos de traducción por vertical├── middleware/ # 🛡️ Middleware de Nuxt (autenticación, rutas)├── pages/ # 📄 Páginas de la aplicación├── plugins/ # 🔌 Plugins de Nuxt├── services/ # 🌐 Servicios de API├── stores/ # 🗄️ Stores de Pinia (estado global)├── types/ # 📝 Tipos TypeScript auxiliares├── utils/ # 🔧 Utilidades puras (sin reactividad)└── error.vue # ❌ Página de errores personalizada🧩 /components/ - Componentes Reutilizables
Section titled “🧩 /components/ - Componentes Reutilizables”Organización por funcionalidad:
components/├── auth/ # 🔐 Componentes de autenticación│ ├── LoginForm.vue│ ├── RegisterForm.vue│ └── ForgotPassword.vue├── ui/ # 🎨 Componentes de interfaz reutilizables│ ├── buttons/ # Botones (MainButton, SecondaryButton)│ ├── forms/ # Inputs, selects, validaciones│ ├── cards/ # Cards para productos, expertos, etc.│ └── modals/ # Modales, popups, notificaciones├── views/ # 📄 Componentes específicos de vistas│ ├── homepage/ # Solo para páginas de inicio│ ├── products/ # Solo para páginas de productos│ └── experts/ # Solo para páginas de expertos└── layouts/ # 📐 Componentes estructurales ├── navbar/ # Navegación principal ├── footer/ # Pie de página └── sidebar/ # Barras laterales¿Dónde va cada componente?
ui/: Si se reutiliza en múltiples páginas →ui/buttons/MainButton.vueviews/: Si es específico de una vista →views/homepage/HeroSection.vuelayouts/: Si es estructura de página →layouts/navbar/Navbar.vue
📄 /pages/ - Sistema de Rutas
Section titled “📄 /pages/ - Sistema de Rutas”¿Cómo funciona el enrutado?
Las páginas se organizan siguiendo la estructura de URLs del sitio:
pages/├── index.vue # → / (página principal)├── cursos/│ ├── index.vue # → /cursos (lista de cursos)│ └── [slug].vue # → /cursos/mi-curso (curso específico)├── series/│ ├── index.vue # → /series (lista de series)│ └── [slug]/│ ├── index.vue # → /series/mi-serie (detalle de serie)│ └── [episode].vue # → /series/mi-serie/episodio-1└── mi-cuenta.vue # → /mi-cuenta (cuenta usuario)📐 /layouts/ - Layouts Dinámicos
Section titled “📐 /layouts/ - Layouts Dinámicos”¿Qué son los layouts?
Los layouts definen la estructura común de las páginas (header, footer, sidebar). El sistema permite:
1. Layouts Comunes (compartidos entre verticales)
Section titled “1. Layouts Comunes (compartidos entre verticales)”layouts/├── default.vue # Layout por defecto└── pages/ # Layouts específicos por página ├── login.vue # Para páginas de login ├── series.vue # Para páginas de series └── courses.vue # Para páginas de cursosUso:
<script setup lang="ts">import { chooseLayoutPage } from "~/core/plugins";
// ✅ Elige automáticamente el layout correctoconst layout = chooseLayoutPage({ page: "series" });</script>
<template> <NuxtLayout :name="layout"> <!-- Contenido específico de la página --> </NuxtLayout></template>2. Layouts Únicos por Vertical
Section titled “2. Layouts Únicos por Vertical”layouts/pages/├── inspiria/ # Layouts exclusivos de Inspiria│ ├── home.vue # Página de inicio de Inspiria│ ├── osteocom.vue # Página específica de Osteocom│ └── kit-digital.vue # Página del Kit Digital└── traumatology/ # Layouts exclusivos de Traumatología └── home.vue # Página de inicio de Traumatología¿Cómo decide el sistema qué layout usar?
La función chooseLayoutPage() busca en este orden:
- Vertical específico:
layouts/pages/inspiria/home.vue - Common/Shared:
layouts/pages/home.vue - Default:
layouts/default.vue
Cómo añadir una nueva página:
Section titled “Cómo añadir una nueva página:”Paso 1: Añadir en /types/pages.ts
export const PAGES = [ // ... páginas existentes "mi-nueva-pagina", // ← Añadir aquí] as const;Paso 2: Añadir traducción de ruta en /config/i18n/pages.ts
export const pages = { // ... rutas existentes "mi-nueva-pagina": { es: "/mi-nueva-pagina", en: "/my-new-page", "pt-BR": "/minha-nova-pagina", },};Paso 3: Crear la página
<script setup lang="ts">const layout = chooseLayoutPage({ page: "mi-nueva-pagina" });</script>
<template> <NuxtLayout :name="layout"> <!-- Contenido de la página --> </NuxtLayout></template>Paso 4: Determinar si es común o específica del vertical
- Común: Crear
layouts/pages/mi-nueva-pagina.vue - Específica: Crear
layouts/pages/inspiria/mi-nueva-pagina.vue
Paso 5: Reiniciar servidor
pnpm dev # Nuxt necesita detectar los nuevos archivos🌐 /services/ - Conexiones a APIs
Section titled “🌐 /services/ - Conexiones a APIs”Organización por alcance:
services/├── fetchApi.ts # 🔧 Cliente HTTP base├── shared/ # 🌍 Servicios compartidos entre verticales│ ├── experts.ts # Gestión de expertos│ ├── series.ts # Series de contenido│ ├── products.ts # Productos y cursos│ └── blog.ts # Artículos del blog├── strapi/ # 🔐 Servicios de autenticación│ ├── login.ts│ ├── registration.ts│ └── userData.ts└── [vertical]/ # 📍 Servicios específicos por vertical ├── inspiria/ │ ├── homePage.ts │ └── osteocom.ts └── pharma/ └── coursePage.ts🗄️ /stores/ - Estado Global con Pinia
Section titled “🗄️ /stores/ - Estado Global con Pinia”¿Qué es Pinia?
Pinia es el gestor de estado oficial de Vue 3. Piensa en él como una “base de datos” en el frontend que guarda información que necesitas en múltiples componentes.
Stores Principales:
Section titled “Stores Principales:”1. auth.ts - Autenticación
export const useAuthStore = defineStore("auth", () => { const jwt = ref<string | null>(localStorage.getItem("jwt")); const isLoggedIn = computed(() => !!jwt.value); const isPremium = ref(false);
const logout = () => { localStorage.removeItem("jwt"); jwt.value = null; navigateTo("/"); };
return { jwt, isLoggedIn, isPremium, logout };});Uso en componentes:
<script setup lang="ts">const { isLoggedIn, logout } = useAuthStore();</script>
<template> <div v-if="isLoggedIn"> <button @click="logout">Cerrar Sesión</button> </div> <div v-else> <NuxtLink to="/login">Iniciar Sesión</NuxtLink> </div></template>2. user.ts - Datos del Usuario
export const useUserStore = defineStore("user", () => { const user = ref<User | null>(null); const isLoading = ref(false);
const fetchUser = async () => { isLoading.value = true; const userData = await useServices("getUserData"); user.value = userData; isLoading.value = false; };
return { user, isLoading, fetchUser };});3. language.ts - Idioma Actual
export const useLanguageStore = defineStore("language", () => { const language = ref<LanguageCode>("es");
const setLanguage = (lang: LanguageCode) => { language.value = lang; localStorage.setItem("language", lang); };
return { language, setLanguage };});¿Cuándo usar Stores vs Composables?
- Store: Datos que necesitas en múltiples páginas (usuario logueado, idioma, carrito)
- Composable: Lógica que se reutiliza pero no necesita persistir (modales, validaciones)
📝 /interfaces/ vs /types/ - ¿Cuándo usar cada uno?
Section titled “📝 /interfaces/ vs /types/ - ¿Cuándo usar cada uno?”📄 /interfaces/ - Estructuras de Datos Complejas
Section titled “📄 /interfaces/ - Estructuras de Datos Complejas”¿Cuándo usar interfaces?
- ✅ Datos que vienen de APIs (respuestas de Strapi)
- ✅ Estructuras complejas con múltiples propiedades
- ✅ Objetos que se extienden o componen
- ✅ Datos de formularios con validaciones
// ✅ interfaces/api/shared/experts.tsexport interface Expert { id: number; name: string; specialization: string; biography: string; image: Media; cv?: string; social_networks?: SocialNetwork[]; created_at: string; updated_at: string;}
// ✅ interfaces/locales/verticals/inspiria.tsexport interface InspiriaHomeLocales { hero: { title: string; subtitle: string; cta_button: string; }; features: { title: string; items: FeatureItem[]; }; testimonials: { title: string; list: TestimonialItem[]; };}📝 /types/ - Tipos Auxiliares y Utilitarios
Section titled “📝 /types/ - Tipos Auxiliares y Utilitarios”¿Cuándo usar types?
- ✅ Uniones de strings literales
- ✅ Tipos auxiliares que modifican otros tipos
- ✅ Configuraciones simples
- ✅ Enums y constantes tipadas
// ✅ types/common.tsexport type LanguageCode = "es" | "en" | "pt-BR";export type AlertType = "success" | "error" | "warning" | "info";export type VerticalName = "inspiria" | "traumatologia" | "salud mental";
// ✅ types/pages.tsexport type LayoutPage = "home" | "login" | "series" | "courses" | "experts";
// ✅ types/forms.tsexport type LoginData = { email: string; password_not_empty: string; youre_a_bot?: string;};🎯 Regla Práctica
Section titled “🎯 Regla Práctica”- ¿Es un objeto complejo con múltiples propiedades? →
interfaces/ - ¿Es un tipo simple, unión o auxiliar? →
types/
🛡️ /middleware/ - Protección de Rutas
Section titled “🛡️ /middleware/ - Protección de Rutas”¿Qué son los middlewares?
Los middlewares son guardianes que se ejecutan antes de que se renderice una página. Permiten:
- ✅ Proteger rutas (solo usuarios logueados)
- ✅ Validar permisos (páginas exclusivas de verticales)
- ✅ Redirections automáticas
- ✅ Configuraciones globales (idioma, modo)
Middlewares Principales:
Section titled “Middlewares Principales:”1. auth.global.ts - Protección de Autenticación
export default defineNuxtRouteMiddleware(async (to) => { const authStore = useAuthStore(); const { isLoggedIn } = storeToRefs(authStore);
// Páginas que requieren login const adminRoutes = ["account", "my-contents"]; const page = (to.name as string).split("___")[0] ?? "";
// Si intenta acceder a página protegida sin login → redirect home if (adminRoutes.includes(page) && !isLoggedIn.value) { return navigateTo("/"); }});2. exclusive-pages.ts - Páginas Exclusivas por Vertical
export default defineNuxtRouteMiddleware((to) => { const realName = (to.name as string)?.split("_")[0] ?? "";
// Verificar si el vertical actual tiene acceso a esta página const canAccess = visibleVertical.exclusivePages?.includes( realName as LayoutPage );
if (visibleVertical && canAccess) return;
// Si no tiene acceso → 404 return createError({ statusCode: 404, fatal: true });});3. language.global.ts - Configuración de Idioma
export default defineNuxtRouteMiddleware(async () => { const { setLanguage } = useLanguageStore();
// Prioridad: 1. LocalStorage 2. Navegador 3. Español const storedLanguage = localStorage.getItem("language") as LanguageCode; const browserLanguage = navigator.language;
setLanguage( storedLanguage ?? normalizeBrowserLanguage(browserLanguage) ?? "es" );});¿Por qué son importantes?
Sin middlewares, los usuarios podrían:
- ❌ Acceder a páginas sin permisos
- ❌ Ver contenido de otros verticales
- ❌ Navegar sin autenticación
- ❌ Tener configuraciones inconsistentes
🛡️ Middleware - Sistema de Protección de Rutas
Section titled “🛡️ Middleware - Sistema de Protección de Rutas”¿Qué es el Middleware?
Section titled “¿Qué es el Middleware?”El middleware en Nuxt es código que se ejecuta antes de renderizar una página. Funciona como un “guardián” que verifica condiciones antes de permitir el acceso a ciertas rutas.
¿Cuándo usar middleware?
- ✅ Autenticación: Verificar si el usuario está logueado
- ✅ Autorización: Comprobar permisos específicos
- ✅ Redirecciones: Redirigir según condiciones (idioma, vertical)
- ✅ Validaciones: Verificar parámetros de ruta
- ✅ Analytics: Registro de acceso a páginas
📁 Estructura del Middleware
Section titled “📁 Estructura del Middleware”app/middleware/├── auth.ts # Protección de autenticación├── exclusive-pages.ts # Páginas exclusivas de verticales├── language.ts # Configuración de idioma└── guest.ts # Solo usuarios no autenticados🔐 auth.ts - Middleware de Autenticación
Section titled “🔐 auth.ts - Middleware de Autenticación”Propósito: Proteger rutas que requieren usuario autenticado
export default defineNuxtRouteMiddleware((to, from) => { const authStore = useAuthStore();
// Si no hay usuario autenticado if (!authStore.isAuthenticated) { // Guardar ruta destino para después del login const redirectTo = to.fullPath;
return navigateTo({ path: "/login", query: { redirect: redirectTo }, }); }
// Usuario autenticado, permitir acceso return;});¿Cómo usarlo en páginas?
<script setup lang="ts">// Aplicar middleware a esta páginadefinePageMeta({ middleware: "auth", // ← Protege toda la página});</script>
<template> <div class="my-account"> <h1>Mi Cuenta</h1> <!-- Solo usuarios autenticados ven esto --> </div></template>Múltiples middleware:
<script setup lang="ts">definePageMeta({ middleware: ["auth", "exclusive-pages"], // ← Ejecuta ambos});</script>🎯 exclusive-pages.ts - Páginas Exclusivas por Vertical
Section titled “🎯 exclusive-pages.ts - Páginas Exclusivas por Vertical”Propósito: Restringir acceso a páginas específicas según el vertical activo
export default defineNuxtRouteMiddleware((to, from) => { const config = useRuntimeConfig(); const currentVertical = config.public.vertical;
// Rutas exclusivas por vertical const exclusiveRoutes = { inspiria: ["/odontologia", "/simuladores", "/dental-courses"], traumatology: ["/trauma-surgery", "/emergency-protocols"], "salud-mental": ["/terapias", "/psicologia", "/mental-health"], pharma: ["/medicamentos", "/farmacologia"], // ...más verticales };
// Verificar si la ruta es exclusiva de otro vertical for (const [vertical, routes] of Object.entries(exclusiveRoutes)) { if ( vertical !== currentVertical && routes.some((route) => to.path.startsWith(route)) ) { // Redirigir a página de error o home throw createError({ statusCode: 404, statusMessage: `Página no disponible en el vertical ${currentVertical}`, }); } }});Ejemplo de uso:
<!-- pages/odontologia/simuladores.vue - Solo para Inspiria --><script setup lang="ts">definePageMeta({ middleware: "exclusive-pages", // ← Solo accesible en vertical Inspiria});</script>🌐 language.ts - Configuración de Idioma
Section titled “🌐 language.ts - Configuración de Idioma”Propósito: Configurar idioma y locale según el vertical y usuario
export default defineNuxtRouteMiddleware((to, from) => { const userStore = useUserStore(); const { locale, setLocale } = useI18n();
// Orden de prioridad para idioma: // 1. Parámetro en URL (/en/page) // 2. Preferencia del usuario autenticado // 3. Configuración del vertical // 4. Idioma por defecto (español)
let targetLocale = "es"; // Fallback por defecto
// 1. Idioma en URL tiene máxima prioridad const urlLocale = to.params.locale as string; if (urlLocale && ["es", "en", "pt-BR"].includes(urlLocale)) { targetLocale = urlLocale; } // 2. Usuario autenticado con preferencia else if (userStore.isAuthenticated && userStore.user?.language) { targetLocale = userStore.user.language; } // 3. Configuración del vertical else { const config = useRuntimeConfig(); const verticalConfig = getVerticalConfig(config.public.vertical); targetLocale = verticalConfig.defaultLanguage || "es"; }
// Aplicar el idioma si es diferente al actual if (locale.value !== targetLocale) { setLocale(targetLocale); }});🚫 guest.ts - Solo Usuarios No Autenticados
Section titled “🚫 guest.ts - Solo Usuarios No Autenticados”Propósito: Redirigir usuarios ya logueados lejos de páginas como login/register
export default defineNuxtRouteMiddleware((to, from) => { const authStore = useAuthStore();
// Si el usuario YA está autenticado if (authStore.isAuthenticated) { // Redirigir a su dashboard o página principal return navigateTo("/mi-cuenta"); }
// Usuario no autenticado, permitir acceso a login/register return;});Uso en páginas de login/register:
<script setup lang="ts">definePageMeta({ middleware: "guest", // ← Solo usuarios NO autenticados});</script>🔄 Middleware Global vs Por Página
Section titled “🔄 Middleware Global vs Por Página”Middleware Global
Section titled “Middleware Global”Se ejecuta en todas las páginas automáticamente:
export default defineNuxtConfig({ router: { middleware: ["language"], // ← Se ejecuta siempre },});Middleware por Página
Section titled “Middleware por Página”Se ejecuta solo en páginas específicas:
<script setup lang="ts">definePageMeta({ middleware: "auth", // ← Solo en esta página});</script>Middleware por Layout
Section titled “Middleware por Layout”Se ejecuta en todas las páginas que usen ese layout:
<script setup lang="ts">definePageMeta({ middleware: "auth", // ← Todas las páginas con layout="dashboard"});</script>🛠️ Crear Middleware Personalizado
Section titled “🛠️ Crear Middleware Personalizado”Paso 1: Crear el archivo
Section titled “Paso 1: Crear el archivo”export default defineNuxtRouteMiddleware((to, from) => { const userStore = useUserStore();
// Verificar que esté autenticado Y sea admin if (!userStore.isAuthenticated) { return navigateTo("/login"); }
if (!userStore.user?.isAdmin) { throw createError({ statusCode: 403, statusMessage: "Acceso denegado: Se requieren permisos de administrador", }); }});Paso 2: Usar en páginas
Section titled “Paso 2: Usar en páginas”<script setup lang="ts">definePageMeta({ middleware: "admin", // ← Solo administradores});</script>📊 Middleware con Parámetros Dinámicos
Section titled “📊 Middleware con Parámetros Dinámicos”export default defineNuxtRouteMiddleware((to, from) => { const userStore = useUserStore();
// Obtener rol requerido de meta de página const requiredRole = to.meta.requiredRole as string;
if (!userStore.user?.roles?.includes(requiredRole)) { throw createError({ statusCode: 403, statusMessage: `Se requiere rol: ${requiredRole}`, }); }});Uso con parámetros:
<script setup lang="ts">definePageMeta({ middleware: "role", requiredRole: "instructor", // ← Parámetro personalizado});</script>🎯 Ejemplos Prácticos Combinados
Section titled “🎯 Ejemplos Prácticos Combinados”Página de Perfil de Instructor
Section titled “Página de Perfil de Instructor”<script setup lang="ts">definePageMeta({ middleware: ["auth", "exclusive-pages"], // ← Autenticado + vertical correcto layout: "instructor", // ← Layout específico});
const route = useRoute();const instructorId = route.params.id;</script>Dashboard de Administración
Section titled “Dashboard de Administración”<script setup lang="ts">definePageMeta({ middleware: ["auth", "admin"], // ← Autenticado + administrador layout: "admin",});</script>Página Pública con Idioma
Section titled “Página Pública con Idioma”<script setup lang="ts">definePageMeta({ middleware: "language", // ← Solo configuración de idioma});</script>⚠️ Manejo de Errores en Middleware
Section titled “⚠️ Manejo de Errores en Middleware”export default defineNuxtRouteMiddleware((to, from) => { const userStore = useUserStore();
try { // Verificar suscripción activa if (!userStore.user?.subscription?.isActive) { // Redirigir a página de suscripción return navigateTo("/suscripcion?expired=true"); } } catch (error) { // Error en verificación, redirigir a error throw createError({ statusCode: 500, statusMessage: "Error verificando suscripción", }); }});🔍 Debug y Testing de Middleware
Section titled “🔍 Debug y Testing de Middleware”// app/middleware/debug.ts (solo para desarrollo)export default defineNuxtRouteMiddleware((to, from) => { if (process.dev) { console.log("🛡️ Middleware ejecutándose:", { to: to.path, from: from?.path, user: useAuthStore().user?.email, vertical: useRuntimeConfig().public.vertical, }); }});📋 Checklist para Middleware
Section titled “📋 Checklist para Middleware”✅ Antes de crear middleware:
- ¿Es lógica que debe ejecutarse en múltiples páginas?
- ¿Puede resolverse mejor con un composable?
- ¿Necesita acceso a
toyfromroutes?
✅ Al implementar middleware:
- Manejar casos de error con
createError() - Usar
navigateTo()para redirecciones - Verificar que funcione en SSR y cliente
- Considerar performance en rutas frecuentes
✅ Al usar middleware en páginas:
- Orden correcto si usas múltiples middleware
- Verificar que no entre en bucles de redirección
- Probar todos los escenarios de acceso
- Documentar propósito en comentarios
🗃️ Stores (Pinia) - Gestión de Estado Global
Section titled “🗃️ Stores (Pinia) - Gestión de Estado Global”¿Qué es Pinia?
Section titled “¿Qué es Pinia?”Pinia es la solución oficial de gestión de estado para Vue 3. Es más simple, más potente y tiene mejor soporte para TypeScript que Vuex.
¿Cuándo usar stores?
- ✅ Estado compartido entre múltiples componentes
- ✅ Datos de usuario (perfil, preferencias, autenticación)
- ✅ Configuraciones globales (idioma, tema, vertical)
- ✅ Cache de APIs que se usa en varias páginas
- ✅ Estado de la aplicación (modales abiertos, loading global)
📁 Estructura de Stores
Section titled “📁 Estructura de Stores”app/stores/├── auth.ts # 🔐 Autenticación y sesión├── user.ts # 👤 Datos del usuario├── language.ts # 🌐 Configuración de idioma├── modal.ts # 🖼️ Estado de modales└── cart.ts # 🛒 Carrito de compras (ejemplo)🔐 auth.ts - Store de Autenticación
Section titled “🔐 auth.ts - Store de Autenticación”Propósito: Manejar el estado de autenticación, login, logout y tokens
export const useAuthStore = defineStore("auth", () => { // 🔄 Estado reactivo const isAuthenticated = ref(false); const token = ref<string | null>(null); const refreshToken = ref<string | null>(null); const loginLoading = ref(false);
// 💾 Persistencia automática const { $storage } = useNuxtApp();
// 🎯 Getters computados const isLoggedIn = computed( () => isAuthenticated.value && token.value !== null ); const authHeaders = computed(() => ({ Authorization: `Bearer ${token.value}`, }));
// 🔧 Actions (métodos) const login = async (credentials: LoginCredentials) => { try { loginLoading.value = true;
const response = await $fetch<AuthResponse>("/api/auth/login", { method: "POST", body: credentials, });
// Guardar tokens token.value = response.accessToken; refreshToken.value = response.refreshToken; isAuthenticated.value = true;
// Persistir en localStorage $storage.setItem("auth_token", response.accessToken); $storage.setItem("refresh_token", response.refreshToken);
// Cargar datos del usuario const userStore = useUserStore(); await userStore.fetchUserProfile();
return response; } catch (error) { console.error("Error en login:", error); throw error; } finally { loginLoading.value = false; } };
const logout = async () => { try { // Llamar API de logout if (token.value) { await $fetch("/api/auth/logout", { method: "POST", headers: authHeaders.value, }); } } catch (error) { console.warn("Error en logout API:", error); } finally { // Limpiar estado local token.value = null; refreshToken.value = null; isAuthenticated.value = false;
// Limpiar storage $storage.removeItem("auth_token"); $storage.removeItem("refresh_token");
// Limpiar otros stores const userStore = useUserStore(); userStore.$reset();
// Redirigir a home await navigateTo("/"); } };
const refreshAuthToken = async () => { if (!refreshToken.value) { throw new Error("No refresh token available"); }
try { const response = await $fetch<AuthResponse>("/api/auth/refresh", { method: "POST", body: { refreshToken: refreshToken.value }, });
token.value = response.accessToken; $storage.setItem("auth_token", response.accessToken);
return response.accessToken; } catch (error) { // Si falla el refresh, hacer logout await logout(); throw error; } };
const initializeAuth = () => { // Recuperar tokens del localStorage al iniciar la app const storedToken = $storage.getItem("auth_token"); const storedRefreshToken = $storage.getItem("refresh_token");
if (storedToken) { token.value = storedToken; refreshToken.value = storedRefreshToken; isAuthenticated.value = true; } };
return { // Estado isAuthenticated: readonly(isAuthenticated), token: readonly(token), loginLoading: readonly(loginLoading),
// Getters isLoggedIn, authHeaders,
// Actions login, logout, refreshAuthToken, initializeAuth, };});👤 user.ts - Store de Usuario
Section titled “👤 user.ts - Store de Usuario”Propósito: Gestionar datos del perfil, preferencias y configuraciones del usuario
export const useUserStore = defineStore("user", () => { // 🔄 Estado del usuario const profile = ref<UserProfile | null>(null); const preferences = ref<UserPreferences>({ language: "es", theme: "light", notifications: true, emailMarketing: false, }); const loading = ref(false); const error = ref<string | null>(null);
// 🎯 Getters const fullName = computed(() => { if (!profile.value) return ""; return `${profile.value.firstName} ${profile.value.lastName}`.trim(); });
const isEmailVerified = computed(() => profile.value?.emailVerified ?? false);
const hasPermission = computed(() => (permission: string) => { return profile.value?.permissions?.includes(permission) ?? false; });
const enrolledCourses = computed(() => profile.value?.enrollments ?? []);
// 🔧 Actions const fetchUserProfile = async () => { const authStore = useAuthStore(); if (!authStore.isLoggedIn) return;
try { loading.value = true; error.value = null;
const response = await $fetch<UserProfile>("/api/user/profile", { headers: authStore.authHeaders, });
profile.value = response;
// Sincronizar preferencias if (response.preferences) { preferences.value = { ...preferences.value, ...response.preferences }; } } catch (err) { error.value = "Error cargando perfil de usuario"; console.error("Error fetching user profile:", err); } finally { loading.value = false; } };
const updateProfile = async (updates: Partial<UserProfile>) => { const authStore = useAuthStore(); if (!authStore.isLoggedIn || !profile.value) return;
try { loading.value = true;
const response = await $fetch<UserProfile>("/api/user/profile", { method: "PUT", headers: authStore.authHeaders, body: updates, });
profile.value = response;
// Mostrar notificación de éxito const { showAlert } = useShowAlert(); showAlert("Perfil actualizado correctamente", "success"); } catch (err) { error.value = "Error actualizando perfil"; throw err; } finally { loading.value = false; } };
const updatePreferences = async ( newPreferences: Partial<UserPreferences> ) => { const authStore = useAuthStore(); if (!authStore.isLoggedIn) return;
try { // Actualizar estado local inmediatamente preferences.value = { ...preferences.value, ...newPreferences };
// Sincronizar con servidor await $fetch("/api/user/preferences", { method: "PUT", headers: authStore.authHeaders, body: newPreferences, });
// Si cambió el idioma, actualizar store de idioma if (newPreferences.language) { const languageStore = useLanguageStore(); languageStore.setLanguage(newPreferences.language); } } catch (err) { console.error("Error updating preferences:", err); // Revertir cambios en caso de error await fetchUserProfile(); } };
const enrollInCourse = async (courseId: string) => { const authStore = useAuthStore(); if (!authStore.isLoggedIn) return;
try { loading.value = true;
await $fetch("/api/courses/enroll", { method: "POST", headers: authStore.authHeaders, body: { courseId }, });
// Actualizar perfil para reflejar la nueva inscripción await fetchUserProfile(); } catch (err) { error.value = "Error inscribiendo en el curso"; throw err; } finally { loading.value = false; } };
// 🔄 Reset store const $reset = () => { profile.value = null; preferences.value = { language: "es", theme: "light", notifications: true, emailMarketing: false, }; loading.value = false; error.value = null; };
return { // Estado profile: readonly(profile), preferences: readonly(preferences), loading: readonly(loading), error: readonly(error),
// Getters fullName, isEmailVerified, hasPermission, enrolledCourses,
// Actions fetchUserProfile, updateProfile, updatePreferences, enrollInCourse, $reset, };});🌐 language.ts - Store de Idioma
Section titled “🌐 language.ts - Store de Idioma”Propósito: Gestionar idioma actual, traducciones disponibles y cambios de idioma
export const useLanguageStore = defineStore("language", () => { // 🔄 Estado de idioma const currentLanguage = ref<LanguageCode>("es"); const availableLanguages = ref<Language[]>([ { code: "es", name: "Español", flag: "🇪🇸" }, { code: "en", name: "English", flag: "🇺🇸" }, { code: "pt-BR", name: "Português", flag: "🇧🇷" }, ]); const isChanging = ref(false);
// 🎯 Getters const currentLanguageData = computed(() => { return availableLanguages.value.find( (lang) => lang.code === currentLanguage.value ); });
const isRTL = computed(() => { const rtlLanguages = ["ar", "he", "fa"]; return rtlLanguages.includes(currentLanguage.value); });
// 🔧 Actions const setLanguage = async (languageCode: LanguageCode) => { if (languageCode === currentLanguage.value) return;
try { isChanging.value = true;
// Validar que el idioma esté disponible const isValidLanguage = availableLanguages.value.some( (lang) => lang.code === languageCode );
if (!isValidLanguage) { console.warn(`Idioma no válido: ${languageCode}`); return; }
// Actualizar estado local currentLanguage.value = languageCode;
// Persistir en localStorage const { $storage } = useNuxtApp(); $storage.setItem("language", languageCode);
// Actualizar i18n de Nuxt const { setLocale } = useI18n(); await setLocale(languageCode);
// Si hay usuario autenticado, actualizar sus preferencias const userStore = useUserStore(); const authStore = useAuthStore();
if (authStore.isLoggedIn) { await userStore.updatePreferences({ language: languageCode }); }
// Actualizar atributos del HTML document.documentElement.lang = languageCode; document.documentElement.dir = isRTL.value ? "rtl" : "ltr"; } catch (error) { console.error("Error cambiando idioma:", error); } finally { isChanging.value = false; } };
const initializeLanguage = () => { const { $storage } = useNuxtApp();
// Orden de prioridad: // 1. localStorage del usuario // 2. Idioma del navegador // 3. Español por defecto
const storedLanguage = $storage.getItem("language") as LanguageCode; const browserLanguage = navigator.language.split("-")[0] as LanguageCode;
let targetLanguage: LanguageCode = "es";
if ( storedLanguage && availableLanguages.value.some((l) => l.code === storedLanguage) ) { targetLanguage = storedLanguage; } else if ( availableLanguages.value.some((l) => l.code === browserLanguage) ) { targetLanguage = browserLanguage; }
setLanguage(targetLanguage); };
const getTranslationProgress = (languageCode: LanguageCode) => { // Función para obtener el progreso de traducción de un idioma // Útil para mostrar al usuario qué tan completa está cada traducción const translationStats = { es: 100, // Español completo en: 85, // Inglés al 85% "pt-BR": 70, // Portugués al 70% };
return translationStats[languageCode] || 0; };
return { // Estado currentLanguage: readonly(currentLanguage), availableLanguages: readonly(availableLanguages), isChanging: readonly(isChanging),
// Getters currentLanguageData, isRTL,
// Actions setLanguage, initializeLanguage, getTranslationProgress, };});🖼️ modal.ts - Store de Modales
Section titled “🖼️ modal.ts - Store de Modales”Propósito: Controlar estado de modales de forma centralizada
export const useModalStore = defineStore("modal", () => { // 🔄 Estado de modales const activeModals = ref<Map<string, ModalConfig>>(new Map()); const modalStack = ref<string[]>([]); // Para manejar múltiples modales
// 🎯 Getters const isAnyModalOpen = computed(() => modalStack.value.length > 0); const currentModal = computed(() => { const topModalId = modalStack.value[modalStack.value.length - 1]; return topModalId ? activeModals.value.get(topModalId) : null; });
// 🔧 Actions const openModal = (modalId: string, config: ModalConfig = {}) => { // Configuración por defecto const defaultConfig: ModalConfig = { closable: true, backdrop: true, size: "medium", animation: "fade", ...config, };
// Guardar configuración del modal activeModals.value.set(modalId, defaultConfig);
// Agregar al stack si no existe if (!modalStack.value.includes(modalId)) { modalStack.value.push(modalId); }
// Bloquear scroll del body cuando hay modal activo if (modalStack.value.length === 1) { document.body.style.overflow = "hidden"; } };
const closeModal = (modalId: string) => { // Remover del stack const index = modalStack.value.indexOf(modalId); if (index > -1) { modalStack.value.splice(index, 1); }
// Remover configuración activeModals.value.delete(modalId);
// Restaurar scroll del body si no hay más modales if (modalStack.value.length === 0) { document.body.style.overflow = ""; } };
const closeAllModals = () => { modalStack.value = []; activeModals.value.clear(); document.body.style.overflow = ""; };
const isModalOpen = (modalId: string) => { return modalStack.value.includes(modalId); };
return { // Estado activeModals: readonly(activeModals), modalStack: readonly(modalStack),
// Getters isAnyModalOpen, currentModal,
// Actions openModal, closeModal, closeAllModals, isModalOpen, };});🛒 cart.ts - Store de Carrito (Ejemplo)
Section titled “🛒 cart.ts - Store de Carrito (Ejemplo)”Propósito: Ejemplo de store para e-commerce/compras
export const useCartStore = defineStore("cart", () => { // 🔄 Estado del carrito const items = ref<CartItem[]>([]); const coupon = ref<Coupon | null>(null); const loading = ref(false);
// 🎯 Getters const totalItems = computed(() => { return items.value.reduce((total, item) => total + item.quantity, 0); });
const subtotal = computed(() => { return items.value.reduce( (total, item) => total + item.price * item.quantity, 0 ); });
const discount = computed(() => { if (!coupon.value) return 0;
if (coupon.value.type === "percentage") { return subtotal.value * (coupon.value.value / 100); } else { return coupon.value.value; } });
const total = computed(() => { return Math.max(0, subtotal.value - discount.value); });
const isEmpty = computed(() => items.value.length === 0);
// 🔧 Actions const addItem = (product: Product, quantity: number = 1) => { const existingItem = items.value.find( (item) => item.productId === product.id );
if (existingItem) { existingItem.quantity += quantity; } else { items.value.push({ productId: product.id, name: product.name, price: product.price, quantity, image: product.image, }); }
// Persistir en localStorage persistCart(); };
const removeItem = (productId: string) => { const index = items.value.findIndex((item) => item.productId === productId); if (index > -1) { items.value.splice(index, 1); persistCart(); } };
const updateQuantity = (productId: string, quantity: number) => { const item = items.value.find((item) => item.productId === productId); if (item) { if (quantity <= 0) { removeItem(productId); } else { item.quantity = quantity; persistCart(); } } };
const applyCoupon = async (couponCode: string) => { try { loading.value = true;
const response = await $fetch<Coupon>("/api/coupons/validate", { method: "POST", body: { code: couponCode, cartTotal: subtotal.value }, });
coupon.value = response; persistCart(); } catch (error) { throw new Error("Código de cupón inválido"); } finally { loading.value = false; } };
const clearCart = () => { items.value = []; coupon.value = null; persistCart(); };
const persistCart = () => { const { $storage } = useNuxtApp(); $storage.setItem("cart", { items: items.value, coupon: coupon.value, }); };
const restoreCart = () => { const { $storage } = useNuxtApp(); const savedCart = $storage.getItem("cart");
if (savedCart) { items.value = savedCart.items || []; coupon.value = savedCart.coupon || null; } };
return { // Estado items: readonly(items), coupon: readonly(coupon), loading: readonly(loading),
// Getters totalItems, subtotal, discount, total, isEmpty,
// Actions addItem, removeItem, updateQuantity, applyCoupon, clearCart, restoreCart, };});🔗 Usando Stores en Componentes
Section titled “🔗 Usando Stores en Componentes”Básico - Acceso directo
Section titled “Básico - Acceso directo”<script setup lang="ts">// Importar y usar storesconst authStore = useAuthStore();const userStore = useUserStore();
// Acceso reactivo al estadoconst isLoggedIn = computed(() => authStore.isLoggedIn);const userName = computed(() => userStore.fullName);</script>
<template> <div class="user-info"> <div v-if="isLoggedIn"> <p>Bienvenido, {{ userName }}</p> <button @click="authStore.logout()">Cerrar Sesión</button> </div> <div v-else> <NuxtLink to="/login">Iniciar Sesión</NuxtLink> </div> </div></template>Avanzado - Con storeToRefs
Section titled “Avanzado - Con storeToRefs”<script setup lang="ts">// Para destructurar estado manteniendo reactividadconst authStore = useAuthStore();const { isAuthenticated, loginLoading } = storeToRefs(authStore);const { login, logout } = authStore; // Actions no necesitan storeToRefs
// Uso con formulariosconst credentials = ref({ email: "", password: "" });
const handleLogin = async () => { try { await login(credentials.value); await navigateTo("/dashboard"); } catch (error) { // Manejar error }};</script>
<template> <form @submit.prevent="handleLogin"> <input v-model="credentials.email" type="email" /> <input v-model="credentials.password" type="password" /> <button :disabled="loginLoading" type="submit"> {{ loginLoading ? "Cargando..." : "Iniciar Sesión" }} </button> </form></template>🔄 Persistencia Automática
Section titled “🔄 Persistencia Automática”Plugin para auto-persistir stores:
export default defineNuxtPlugin(() => { // Restaurar stores al iniciar la app const authStore = useAuthStore(); const userStore = useUserStore(); const languageStore = useLanguageStore(); const cartStore = useCartStore();
// Inicializar en orden authStore.initializeAuth(); languageStore.initializeLanguage(); cartStore.restoreCart();
// Si hay usuario autenticado, cargar su perfil if (authStore.isLoggedIn) { userStore.fetchUserProfile(); }});📊 Testing de Stores
Section titled “📊 Testing de Stores”import { createPinia, setActivePinia } from "pinia";import { describe, it, expect, beforeEach } from "vitest";import { useAuthStore } from "~/stores/auth";
describe("Auth Store", () => { beforeEach(() => { setActivePinia(createPinia()); });
it("should initialize with default state", () => { const store = useAuthStore();
expect(store.isAuthenticated).toBe(false); expect(store.token).toBe(null); expect(store.isLoggedIn).toBe(false); });
it("should login successfully", async () => { const store = useAuthStore();
// Mock de la respuesta de API global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ accessToken: "fake-token", refreshToken: "fake-refresh", }), }) );
await store.login({ email: "test@test.com", password: "password" });
expect(store.isAuthenticated).toBe(true); expect(store.token).toBe("fake-token"); });});📋 Checklist para Stores
Section titled “📋 Checklist para Stores”✅ Al crear un store:
- ¿El estado es realmente compartido entre componentes?
- ¿Las actions son async cuando corresponde?
- ¿Los getters son computed correctamente?
- ¿Uso
readonly()para proteger estado?
✅ Al usar stores:
- Usar
storeToRefs()para destructurar estado reactivo - No destructurar actions (no necesitan storeToRefs)
- Manejar loading states para mejor UX
- Implementar error handling apropiado
✅ Performance:
- No crear stores para estado local simple
- Considerar paginación para listas grandes
- Usar
markRaw()para objetos no reactivos - Implementar cache cuando sea apropiado
2. Información Técnica Completa
- 📍 Archivo y línea donde ocurrió el error
- 🖥️ Información del navegador y dispositivo
- 📄 Stacktrace completo para debugging
- 🐞 Botón para copiar reporte de bug
3. Experiencia de Usuario
- 🎨 Interfaz consistente con el diseño
- 🌍 Textos traducidos según idioma
- 🔄 Botón para recargar página
- 🏠 Botón para volver al inicio
¿Cuándo se activa?
- ❌ Error 404 (página no encontrada)
- ❌ Error 500 (error del servidor)
- ❌ Errores de JavaScript no capturados
- ❌ Fallos en componentes Vue
- ❌ Problemas de conectividad con APIs
🏢 Sistema de Verticales
Section titled “🏢 Sistema de Verticales”¿Qué es un Vertical?
Section titled “¿Qué es un Vertical?”Un vertical es una configuración específica que define cómo se comporta la aplicación para una marca/producto particular (Inspiria, Traumatología, Salud Mental, etc.).
Configuración de Verticales
Section titled “Configuración de Verticales”Cada vertical se define en /app/constants/verticals/[vertical].ts:
export const INSPIRIA: InspiriaVertical = { name: "inspiria", subdomain: "inspiriadental", i18nDirectory: "inspiria", primaryColor: "#92ffac", secondaryColor: "#92ffac", productionURL: "https://pre.inspiriadental.com/", productionApi: "https://multiverticales.alebateducation.com/", developmentApi: "http://localhost:1337", favicon: "/favicon-inspiria.png", verticalLogo: "/logo-inspiria.svg", exclusivePages: [ "plan-site", "dental-procedures-simulator", // ...más páginas exclusivas ], strapiService: "inspiria", services: { // Servicios específicos del vertical }, zohoConfig: { // Configuración de Zoho }, localeDirectory: "inspiria", layoutDirectory: "inspiria", analyticsCode: "G-LHK4QJG0VT",};Tipos de Verticales Disponibles
Section titled “Tipos de Verticales Disponibles”- Inspiria - Odontología
- Traumatología - Medicina de traumatología
- Salud Mental - Psicología y psiquiatría
- Emergencias - Medicina de emergencia
- Oncología - Medicina oncológica
- Pharma - Farmacología
Interface de Verticales
Section titled “Interface de Verticales”La interface Verticals define la estructura base:
export interface Verticals { analyticsCode?: string; developmentApi: `http://localhost:${number}`; exclusivePages?: LayoutPage[]; favicon: string; i18nDirectory: string; layoutDirectory: string; localeDirectory: string; name: string; primaryColor: string; productionApi: string; productionURL: string; secondaryColor: string; services: Partial<CommonServices> & Partial<SpecificServices>; strapiService: | "inspiria" | "traumatologia" | "emergencias" | "salud mental" | "oncologia" | "pharma"; subdomain: string; verticalLogo: string; zohoConfig?: ZohoConfig;}🌍 Internacionalización (i18n) - Sistema de Traducciones
Section titled “🌍 Internacionalización (i18n) - Sistema de Traducciones”¿Qué es i18n vs Locales?
Section titled “¿Qué es i18n vs Locales?”El proyecto maneja traducciones de dos formas diferentes según el tipo de contenido:
🌐 i18n (Global) - Textos Comunes del Sistema
Section titled “🌐 i18n (Global) - Textos Comunes del Sistema”¿Cuándo usar?
- ✅ Navegación (menús, botones, headers)
- ✅ Mensajes del sistema (errores, validaciones, estados)
- ✅ Funcionalidades base (autenticación, formularios)
- ✅ Elementos compartidos entre todos los verticales
// i18n/es.json - Traducciones globales{ "navigation": { "home": "Inicio", "courses": "Cursos", "about": "Acerca de", "contact": "Contacto" }, "auth": { "login": "Iniciar Sesión", "register": "Registrarse", "email_required": "El email es obligatorio", "password_too_short": "La contraseña debe tener al menos 8 caracteres" }, "common": { "loading": "Cargando...", "error": "Ha ocurrido un error", "success": "Operación exitosa", "save": "Guardar", "cancel": "Cancelar" }}Uso en componentes:
<template> <nav class="navbar"> <NuxtLink to="/">{{ $t("navigation.home") }}</NuxtLink> <NuxtLink to="/cursos">{{ $t("navigation.courses") }}</NuxtLink> </nav>
<form @submit="handleSubmit"> <button type="submit">{{ $t("common.save") }}</button> <p v-if="error">{{ $t("common.error") }}</p> </form></template>
<script setup lang="ts">const { t } = useI18n(); // ← Hook global de i18n</script>📍 Locales (Por Vertical) - Contenido Específico
Section titled “📍 Locales (Por Vertical) - Contenido Específico”¿Cuándo usar?
- ✅ Contenido exclusivo de cada vertical
- ✅ Páginas únicas de un vertical
- ✅ Configuraciones específicas (textos de hero, features)
- ✅ Datos estructurados complejos que vienen del CMS
{ "hero": { "title": "Formación Odontológica de Excelencia", "subtitle": "Aprende con los mejores expertos en odontología", "cta_button": "Explorar Cursos de Odontología" }, "features": { "title": "¿Por qué elegir Inspiria Dental?", "items": [ { "title": "Simuladores 3D", "description": "Practica procedimientos con tecnología avanzada" }, { "title": "Expertos Reconocidos", "description": "Aprende de los mejores profesionales en odontología" } ] }}Uso en componentes:
<template> <section class="hero"> <h1>{{ homeLocales?.hero.title }}</h1> <p>{{ homeLocales?.hero.subtitle }}</p> <MainButton>{{ homeLocales?.hero.cta_button }}</MainButton> </section></template>
<script setup lang="ts">const { data } = await useLocales<InspiriaHomeLocales>("homepage");const homeLocales = ref(data); // ← Hook específico del vertical</script>🎯 useLocales - El Composable para Contenido Específico
Section titled “🎯 useLocales - El Composable para Contenido Específico”useLocales es tu herramienta para obtener traducciones específicas del vertical con:
- ✅ Fallback automático a español si falta traducción
- ✅ Tipado fuerte con TypeScript
- ✅ Merge inteligente de datos entre idiomas
- ✅ Configuración por vertical
Tutorial Completo: Cómo usar useLocales
Section titled “Tutorial Completo: Cómo usar useLocales”Paso 1: Crear la Interface TypeScript
export interface LoginFormLocales { login_title: string; email_text: string; password_text: string; forgot_password: string; forgot_password_click: string; general_error: string; button_text: string; to_register_text: string; to_register_click: string; api_error: string; generic_error: string;}Paso 2: Crear los archivos JSON por idioma
{ "login_title": "Iniciar Sesión en Inspiria", "email_text": "Correo electrónico", "password_text": "Contraseña", "forgot_password": "¿Has olvidado tu contraseña?", "forgot_password_click": "Haz clic aquí", "general_error": "Por favor, revisa los campos marcados en rojo", "button_text": "Entrar a mi cuenta", "to_register_text": "¿No tienes cuenta en Inspiria?", "to_register_click": "Regístrate aquí", "api_error": "Error en el servidor", "generic_error": "Ha ocurrido un error inesperado"}{ "login_title": "Sign In to Inspiria", "email_text": "Email address", "password_text": "Password", "forgot_password": "Forgot your password?", "forgot_password_click": "Click here", "general_error": "Please check the fields marked in red", "button_text": "Sign In", "to_register_text": "Don't have an account?", "to_register_click": "Sign up here", "api_error": "Server error", "generic_error": "An unexpected error occurred"}Paso 3: Usar en el componente
<script setup lang="ts">import type { LoginFormLocales } from "~/interfaces/locales/verticals/inspiria/loginForm";
// Cargar las traducciones específicasconst { data } = await useLocales<LoginFormLocales>("login");const loginFormLocales = ref(data);</script>
<template> <form class="login-form"> <h1>{{ loginFormLocales?.login_title }}</h1>
<input :placeholder="loginFormLocales?.email_text ?? ''" type="email" v-model="email" />
<input :placeholder="loginFormLocales?.password_text ?? ''" type="password" v-model="password" />
<button type="submit"> {{ loginFormLocales?.button_text }} </button>
<p class="register-link"> {{ loginFormLocales?.to_register_text }} <NuxtLink to="/register"> {{ loginFormLocales?.to_register_click }} </NuxtLink> </p> </form></template>🎯 Características importantes:
- ✅ Tipado fuerte:
useLocales<LoginFormLocales>()proporciona autocompletado - ✅ Async/await: Es una función asíncrona que necesita
await - ✅ Reactividad: El resultado debe envolverse en
ref()para Vue - ✅ Safe access: Usar optional chaining (
?.) y nullish coalescing (??) - ✅ Matching: El nombre del archivo JSON debe coincidir con el parámetro
🔄 Sistema de Fallback Inteligente
Section titled “🔄 Sistema de Fallback Inteligente”¿Qué pasa si falta una traducción?
useLocales implementa un sistema de fallback que nunca deja textos vacíos:
// Ejemplo interno de cómo funciona:const datosEspanol = await import("~/locales/inspiria/es/login.json");const datosIngles = await import("~/locales/inspiria/en/login.json");
// Si en español falta una clave, la toma de inglésconst resultado = { login_title: datosEspanol.login_title || datosIngles.login_title, email_text: datosEspanol.email_text || datosIngles.email_text, // ... más campos};Ejemplo práctico:
// es/login.json - Español incompleto{ "login_title": "Iniciar Sesión", "email_text": "" // ← Falta traducción}
// en/login.json - Inglés completo{ "login_title": "Sign In", "email_text": "Email address"}
// Resultado final automático:{ "login_title": "Iniciar Sesión", // ← Español preferido "email_text": "Email address" // ← Inglés como fallback}📂 Organización de Archivos de Traducción
Section titled “📂 Organización de Archivos de Traducción”Estructura Global (i18n)
Section titled “Estructura Global (i18n)”i18n/├── es.json # Español global├── en.json # Inglés global└── pt-BR.json # Portugués globalEstructura por Vertical (locales)
Section titled “Estructura por Vertical (locales)”app/locales/├── inspiria/ # Vertical específico│ ├── es/ # Idioma español│ │ ├── homepage.json│ │ ├── login.json│ │ └── courses.json│ ├── en/ # Idioma inglés│ │ ├── homepage.json│ │ ├── login.json│ │ └── courses.json│ └── pt-BR/ # Idioma portugués├── traumatology/ # Otro vertical└── shared/ # Contenido compartido entre verticales🌟 Configuración Global
Section titled “🌟 Configuración Global”La configuración se encuentra en /app/config/i18n.ts:
const i18nConfig: Partial<NuxtI18nOptions> = { defaultLocale: "es", locales: [ { code: "es", file: "es.json", language: "es" }, { code: "en", file: "en.json", language: "en" }, { code: "pt-BR", file: "pt-BR.json", language: "pt-BR" }, ], strategy: "prefix_except_default", // /en/page vs /page (español) lazy: true, // Carga idiomas bajo demanda langDir: "../i18n/", // Directorio de archivos globales};🎨 Ejemplo Combinando i18n y Locales
Section titled “🎨 Ejemplo Combinando i18n y Locales”<template> <!-- Navegación global (i18n) --> <nav class="navbar"> <NuxtLink to="/">{{ $t("navigation.home") }}</NuxtLink> <NuxtLink to="/cursos">{{ $t("navigation.courses") }}</NuxtLink> </nav>
<!-- Contenido específico del vertical (locales) --> <section class="hero"> <h1>{{ homeLocales?.hero.title }}</h1> <p>{{ homeLocales?.hero.subtitle }}</p> <MainButton>{{ homeLocales?.hero.cta_button }}</MainButton> </section>
<!-- Formulario mezclando ambos --> <form @submit="handleSubmit"> <input :placeholder="$t('auth.email')" v-model="email" /> <button type="submit">{{ $t("common.save") }}</button> <p v-if="error">{{ homeLocales?.error_messages.general }}</p> </form></template>
<script setup lang="ts">// Global i18nconst { t } = useI18n();
// Específico del verticalconst { data } = await useLocales<HomePageLocales>("homepage");const homeLocales = ref(data);</script>🎯 Resultado: Una experiencia perfecta donde la navegación es consistente globalmente, pero el contenido se adapta a cada vertical.
🔗 Sistema de Servicios y APIs
Section titled “🔗 Sistema de Servicios y APIs”¿Qué son los Servicios?
Section titled “¿Qué son los Servicios?”Los servicios son funciones que conectan tu aplicación con APIs externas (como Strapi). Piensa en ellos como embajadores que saben cómo hablar con diferentes servidores y traer los datos que necesitas.
🎯 useServices - El Composable Principal
Section titled “🎯 useServices - El Composable Principal”useServices es tu herramienta principal para obtener datos. Es un composable que:
- ✅ Gestiona idiomas automáticamente (español principal, inglés fallback)
- ✅ Maneja errores sin romper la aplicación
- ✅ Tipado fuerte con TypeScript para evitar errores
- ✅ Fallback inteligente si faltan datos en un idioma
Ejemplo Básico - Sin Parámetros
Section titled “Ejemplo Básico - Sin Parámetros”// Obtener datos estáticos de una páginaconst homePage = await useServices<HomePageData>("getHomePage");const legalPages = await useServices<LegalPagesApi>("getLegalPages");¿Qué está pasando aquí?
useServicesbusca el serviciogetHomePageen el vertical actual- Lo ejecuta con el idioma del usuario (ej: español)
- Si falla o está incompleto, intenta en inglés como fallback
- Retorna los datos combinados y tipados
Ejemplo Avanzado - Con Parámetros
Section titled “Ejemplo Avanzado - Con Parámetros”// Obtener datos dinámicos con filtrosconst expertData = await useServices<Expert[], FilterParams>("getExperts", { vertical: visibleVertical.name ?? "", category: "odontologia",});
// Obtener un producto específico por slugconst producto = await useServices<DetailProduct, FilterParams>( "getProductBySlug", { slug: route.params.slug as string, vertical: visibleVertical.name ?? "", });
// Obtener datos con modo reactivo (se actualiza automáticamente)const { data: courses, isPending } = await useServices<Course[]>( "getCourses", undefined, { reactive: true, });🏗️ Arquitectura de Servicios
Section titled “🏗️ Arquitectura de Servicios”Client HTTP Base
Section titled “Client HTTP Base”// /app/services/fetchApi.ts - El "motor" que hace las peticionesclass HttpClient { private url() { // Decide si usar API de desarrollo o producción return isProduction() ? visibleVertical.productionApi : visibleVertical.developmentApi; }
public async get<T>({ resource, params = [] }) { return this.fetching<T>({ method: "GET", resource, params }); }
public async post<T>({ resource, body, params = [] }) { return this.fetching<T>({ method: "POST", resource, body, params }); }}
export const httpClient = new HttpClient();Estructura Organizada
Section titled “Estructura Organizada”services/├── fetchApi.ts # 🔧 Cliente HTTP base├── shared/ # 🌍 Servicios compartidos entre verticales│ ├── experts.ts # Gestión de expertos│ ├── series.ts # Series de contenido│ ├── products.ts # Productos y cursos│ └── blog.ts # Artículos del blog├── strapi/ # 🔐 Servicios de autenticación con Strapi│ ├── login.ts│ ├── registration.ts│ └── userData.ts└── [vertical]/ # 📍 Servicios específicos por vertical ├── inspiria/ │ ├── homePage.ts │ └── osteocom.ts └── pharma/ └── coursePage.ts🛠️ Cómo Crear un Servicio - Tutorial Paso a Paso
Section titled “🛠️ Cómo Crear un Servicio - Tutorial Paso a Paso”Ejemplo Real: Servicio getUser
Vamos a crear un servicio para obtener datos de usuarios y entender cómo funciona todo el sistema.
Paso 1: Crear el Archivo del Servicio
Section titled “Paso 1: Crear el Archivo del Servicio”import type { LanguageCode } from "~/types/common";import type { FilterParams } from "~/types/shared/common";import type { User } from "~/interfaces/api/shared/users";
export const getUser = async ( lang: LanguageCode, { userId }: { userId: string }) => { try { const { find } = useStrapi(); const { data: response } = await find<User>("users", { locale: lang, filters: { id: { $eq: userId }, }, fields: ["id", "username", "email", "displayName"], populate: { avatar: { fields: ["id", "url", "alternativeText"] }, preferences: { fields: ["theme", "notifications"] }, }, });
return response[0] as User; // Retorna el primer usuario encontrado } catch (error) { console.error("Error en getUser:", error); return null; // IMPORTANTE: Siempre retornar null en catch }};🎯 Puntos Clave:
- ✅ Tipado fuerte:
LanguageCode,FilterParams,User - ✅ Manejo de errores: try/catch con return null
- ✅ Populate selectivo: Solo traer los campos necesarios
- ✅ Locale: Respeta el idioma del usuario
Paso 2: Definir la Interface TypeScript
Section titled “Paso 2: Definir la Interface TypeScript”export interface User { id: number; username: string; email: string; displayName: string; avatar?: Media; preferences?: UserPreferences; created_at: string; updated_at: string;}
export interface UserPreferences { theme: "light" | "dark"; notifications: boolean; language: LanguageCode;}Paso 3: Registrar en la Configuración del Vertical
Section titled “Paso 3: Registrar en la Configuración del Vertical”// /app/interfaces/verticals.ts - Añadir a CommonServicesinterface CommonServices { // ... otros servicios existentes getUser: FunctionService<User, { userId: string }>; // ← Añadir aquí}// /app/constants/verticals/inspiria.ts - Implementar en cada verticalimport { getUser } from "~/services/shared/users";
export const INSPIRIA: InspiriaVertical = { // ... otras propiedades services: { // ... otros servicios getUser: async (lang, params) => await getUser(lang, params), // ← Implementar },};¿Por qué este proceso?
- getUser es GLOBAL → Se define en
CommonServices→ Está disponible en TODAS las verticales - getSeries específico → Se define solo en la interfaz de Inspiria → Solo disponible en Inspiria
- Tipado garantizado → TypeScript verifica que todos los servicios estén implementados
Paso 4: Usar el Servicio
Section titled “Paso 4: Usar el Servicio”<!-- En cualquier componente --><script setup lang="ts">// Usar el servicio que acabamos de crearconst route = useRoute();const userId = route.params.id as string;
const { data: userData, isPending } = await useServices< User, { userId: string }>("getUser", { userId }, { reactive: true });</script>
<template> <div v-if="isPending">Cargando usuario...</div> <div v-else-if="userData" class="user-profile"> <img :src="userData.avatar?.url" :alt="userData.displayName" /> <h1>{{ userData.displayName }}</h1> <p>{{ userData.email }}</p> </div> <div v-else>Usuario no encontrado</div></template>🔄 Cómo Funciona el Fallback de Idiomas
Section titled “🔄 Cómo Funciona el Fallback de Idiomas”Problema que resuelve: A veces el contenido no está traducido completamente. Por ejemplo:
- ✅ Título en español: “Curso de Odontología Avanzada”
- ❌ Descripción en español: vacía
- ✅ Descripción en inglés: “Advanced dentistry techniques course”
Solución automática:
// useServices hace esto automáticamente:const datosEspanol = await getUser("es", { userId: "123" });const datosIngles = await getUser("en", { userId: "123" });
// Combina ambos resultados:const resultado = { ...datosEspanol, title: datosEspanol.title || datosIngles.title, // Español preferido description: datosEspanol.description || datosIngles.description, // Inglés si falta español};Resultado final: Siempre tienes datos completos, priorizando el idioma del usuario pero completando con inglés lo que falte.
🚀 Servicios Específicos vs Compartidos
Section titled “🚀 Servicios Específicos vs Compartidos”Servicios Compartidos (/services/shared/)
Section titled “Servicios Compartidos (/services/shared/)”¿Cuándo usar?
- ✅ Funcionalidad común a todos los verticales
- ✅ Datos estándar (usuarios, blogs, productos básicos)
- ✅ Reutilización máxima
// ✅ Ejemplo: Expertos (todos los verticales tienen expertos)export const getExperts = async ( lang: LanguageCode, { vertical }: FilterParams) => { const { find } = useStrapi(); const { data } = await find<Expert[]>("experts", { locale: lang, filters: { verticals: { $containsi: vertical }, // Filtrar por vertical }, // ... configuración común }); return data;};Servicios Específicos (/services/[vertical]/)
Section titled “Servicios Específicos (/services/[vertical]/)”¿Cuándo usar?
- ✅ Funcionalidad única del vertical
- ✅ Endpoints específicos que no existen en otros verticales
- ✅ Lógica especializada
// ✅ Ejemplo: Osteocom (solo existe en Inspiria)export const getOsteocomCourse = async (courseSlug: string) => { try { const client = useStrapiClient(); const response = await client<OsteocomCourse>( `/osteocom/courses/${courseSlug}`, // ← Endpoint específico de Inspiria { method: "GET" } ); return response; } catch { return null; }};📊 Estructuras de API - DRY Principle
Section titled “📊 Estructuras de API - DRY Principle”Problema: Repetir la misma configuración de populate en múltiples servicios.
❌ Sin estructura reutilizable:
// En cada servicio repetimos lo mismoconst { data } = await find("products", { populate: { image: { fields: ["id", "url", "alternativeText"] }, experts: { fields: ["id", "name"] }, seo: { fields: ["title", "description"] }, },});✅ Con estructura reutilizable:
export const PRODUCT_STRUCTURE = { fields: ["id", "title", "type", "SKU", "slug"], populate: { image: { fields: ["id", "url", "alternativeText"] }, experts: { fields: ["id", "name"] }, seo: { fields: ["title", "description"] }, },} as const;
// En servicios - mucho más limpio:const { data } = await find("products", { ...PRODUCT_STRUCTURE, filters: { vertical: { $containsi: vertical } },});🎯 Beneficios:
- ✅ DRY: No repetir código
- ✅ Consistencia: Mismos campos en todos lados
- ✅ Mantenibilidad: Cambio en un lugar se aplica a todos
- ✅ Legibilidad: Servicios más limpios y enfocados
Estructura Actual de Servicios
Section titled “Estructura Actual de Servicios”services/├── fetchApi.ts # Cliente HTTP base (HttpClient, ZohoClient)├── shared/ # Servicios compartidos entre verticales│ ├── blog.ts # Gestión de blogs│ ├── categories.ts # Categorías de contenido│ ├── checkout.ts # Proceso de compra│ ├── episodes.ts # Episodios de series│ ├── experts.ts # Gestión de expertos│ ├── legalPages.ts # Páginas legales│ ├── lives.ts # Transmisiones en vivo│ ├── orders.ts # Gestión de pedidos│ ├── partners.ts # Socios/Partners│ ├── products.ts # Productos│ └── series.ts # Series de contenido├── strapi/ # Servicios de autenticación con Strapi│ ├── login.ts│ ├── registration.ts│ └── userData.ts├── inspiria/ # Servicios específicos de Inspiria│ ├── allContents.ts│ ├── coursePage.ts│ ├── dentalProceduresPageServices.ts│ ├── inspiriaHomePageService.ts│ ├── masterPage.ts│ ├── osteocom.ts│ └── talks.ts├── mental-health/ # Servicios específicos de Salud Mental└── pharma/ # Servicios específicos de PharmaCrear un Servicio Compartido
Section titled “Crear un Servicio Compartido”Paso 1: Crear el archivo del servicio
import type { LanguageCode } from "~/types/common";import type { FilterParams } from "~/types/shared/common";import type { MiNuevoTipo } from "~/interfaces/api/shared/miNuevoTipo";import { SEO } from "~/constants/api-structures/commonStructure";
// Tipos específicos para populate params (si es necesario)type MiNuevoTipoPopulateParams = Omit<MiNuevoTipo, "relaciones"> & { relaciones: RelacionTipo[];};
export const getMiNuevoServicio = async ( lang: LanguageCode, { vertical }: FilterParams) => { try { const { find } = useStrapi(); const { data: response } = await find<MiNuevoTipoPopulateParams>( "mi-nuevo-endpoint", { locale: lang, filters: { vertical: { $containsi: vertical }, // Otros filtros específicos }, fields: ["id", "title", "description", "slug"], populate: { image: { fields: ["id", "url", "alternativeText"] }, seo: SEO, // Otras relaciones usando estructuras predefinidas }, sort: ["createdAt:desc"], } );
return response as MiNuevoTipo[]; } catch (error) { console.error("Error en getMiNuevoServicio:", error); return null; }};
export const getMiNuevoServicioPorSlug = async ( lang: LanguageCode, { slug, vertical }: FilterParams) => { try { const { find } = useStrapi(); const { data } = await find<MiNuevoTipoPopulateParams>( "mi-nuevo-endpoint", { locale: lang, filters: { slug: { $eq: slug }, vertical: { $containsi: vertical }, }, fields: ["id", "title", "description", "content"], populate: { image: { fields: ["id", "url", "alternativeText"] }, seo: SEO, // Populate más detallado para vista individual }, } );
const response = data[0]; return response as MiNuevoTipo; } catch (error) { console.error("Error en getMiNuevoServicioPorSlug:", error); return null; }};Crear un Servicio Específico de Vertical
Section titled “Crear un Servicio Específico de Vertical”Paso 1: Crear el directorio del vertical (si no existe)
mkdir app/services/nuevo-verticalPaso 2: Crear el archivo del servicio
import type { LanguageCode } from "~/types/common";import type { NuevoVerticalHomeApi } from "~/interfaces/api/verticals/nuevo-vertical/home";import { CAROUSEL_MEDIA, PARTNERS_RELATION, SERIES_RELATION,} from "~/constants/api-structures/homeStructure";import { SEO } from "~/constants/api-structures/commonStructure";
type NuevoVerticalHomePopulateParams = NuevoVerticalHomeApi & { // Tipos específicos si necesitas override};
export const getNuevoVerticalHomePage = async (lang: LanguageCode) => { try { const { findOne } = useStrapi(); const { data: response } = await findOne<NuevoVerticalHomePopulateParams>( "nuevo-vertical-home", undefined, { locale: lang, populate: { carouselMedia: CAROUSEL_MEDIA, partners: PARTNERS_RELATION, series: SERIES_RELATION, featuresEspecificas: { fields: ["id", "title", "description"], populate: { icon: { fields: ["id", "url", "alternativeText"] }, }, }, seo: SEO, }, } );
return response as NuevoVerticalHomeApi; } catch (error) { console.error("Error en getNuevoVerticalHomePage:", error); return null; }};Registrar el Servicio en la Configuración del Vertical
Section titled “Registrar el Servicio en la Configuración del Vertical”Paso 3: Agregar el servicio a la configuración
import { getNuevoVerticalHomePage } from "~/services/nuevo-vertical/homePageService";import { getMiNuevoServicio } from "~/services/shared/miNuevoServicio";
export const NUEVO_VERTICAL: NuevoVertical = { // ...otras propiedades services: { // Servicios compartidos (heredados) getExperts: async (params) => await getExperts("es", params), getBlogs: async (params) => await getBlogs("es", params),
// Servicios compartidos nuevos getMiNuevoServicio: async (params) => await getMiNuevoServicio("es", params),
// Servicios específicos del vertical getHomePage: async () => await getNuevoVerticalHomePage("es"),
// Servicios con lógica específica getFuncionEspecifica: async (params) => { // Lógica específica del vertical const baseData = await getMiNuevoServicio("es", params); const filteredData = baseData?.filter( (item) => item.categoria === "especial" ); return filteredData; }, },};Usar el Servicio con useServices
Section titled “Usar el Servicio con useServices”Paso 4: Implementar en componentes
// En cualquier componente o páginaconst { data: miNuevosDatos } = await useServices<MiNuevoTipo[]>( "getMiNuevoServicio");const { data: homeData } = await useServices<NuevoVerticalHomeApi>( "getHomePage");
// Con parámetrosconst { data: datosEspecificos } = await useServices< MiNuevoTipo[], FilterParams>("getMiNuevoServicio", { vertical: visibleVertical.name,});Buenas Prácticas para Servicios
Section titled “Buenas Prácticas para Servicios”✅ Hacer
Section titled “✅ Hacer”// Manejo de errores consistenteexport const getServicio = async (lang: LanguageCode, params: FilterParams) => { try { const { find } = useStrapi() const { data: response } = await find<TipoRespuesta>('endpoint', { // configuración }) return response as TipoRetorno[] } catch (error) { console.error('Error específico en getServicio:', error) return null // o array vacío según el caso }}
// Uso de estructuras predefinidaspopulate: { image: { fields: ['id', 'url', 'alternativeText'] }, seo: SEO, experts: EXPERTS_STRUCTURE,}
// Tipos específicos para populatetype ServicePopulateParams = Omit<TipoOriginal, 'relacion'> & { relacion: TipoRelacionTransformado[]}❌ Evitar
Section titled “❌ Evitar”// Sin manejo de erroresexport const getServicio = async (params) => { const { data } = await find('endpoint', params) return data // ¿Qué pasa si falla?}
// Populate hardcodeado repetitivopopulate: { image: { fields: ['id', 'url', 'alternativeText', 'width', 'height'] }, // Repetir esta estructura en cada servicio}
// Sin tipos TypeScriptexport const getServicio = async (params: any): Promise<any> => { // ...}Servicios con HttpClient (APIs externas)
Section titled “Servicios con HttpClient (APIs externas)”Para servicios que no usen Strapi:
import { httpClient } from "~/services/fetchApi";
export const getDataExterna = async (parametros: ParametrosTipo) => { try { const { response } = await httpClient.get<TipoRespuesta>({ resource: "api-externa/endpoint", params: [ { name: "filtro", value: parametros.filtro }, { name: "limite", value: "10" }, ], });
return response as TipoRespuesta[]; } catch (error) { console.error("Error en servicio externo:", error); return null; }};🧩 Componentes y Metodología BEM
Section titled “🧩 Componentes y Metodología BEM”Metodología BEM
Section titled “Metodología BEM”BEM (Block Element Modifier) es obligatorio para todas las clases CSS:
// ❌ Incorrecto.card { .title { font-size: 2em; &.big { font-size: 2em; } }}
// ✅ Correcto.card { &__title { font-size: 2em;
&--large { font-size: 2.5em; } }}Estructura de Componentes
Section titled “Estructura de Componentes”Componente Base Example
Section titled “Componente Base Example”<script setup lang="ts">interface Props { title: string; variant?: "primary" | "secondary"; size?: "small" | "medium" | "large";}
const props = withDefaults(defineProps<Props>(), { variant: "primary", size: "medium",});
// Lógica del componente usando composablesconst { isVisible } = useModal();</script>
<template> <div class="card" :class="`card--${variant}`"> <h2 class="card__title">{{ title }}</h2> <div class="card__content"> <slot /> </div> </div></template>
<style lang="scss" scoped>.card { padding: var(--s-spacing-medium); border-radius: var(--s-border-radius-medium);
&--primary { background-color: var(--c-primary); }
&--secondary { background-color: var(--c-secondary); }
&__title { font-family: var(--f-heading); font-size: var(--s-font-heading-h3); margin: 0 0 var(--s-spacing-small) 0; }
&__content { color: var(--c-text-primary); }}</style>Composables Pattern
Section titled “Composables Pattern”Los composables siguen el patrón use[Funcionalidad]:
export const useModal = () => { const isVisible = ref(false);
const open = () => { isVisible.value = true; };
const close = () => { isVisible.value = false; };
// Cleanup en unmounted onUnmounted(() => { close(); });
return { isVisible: readonly(isVisible), open, close, };};Reglas de Componentes
Section titled “Reglas de Componentes”✅ Permitido
Section titled “✅ Permitido”<script setup>con TypeScript- Props tipados con interfaces
- Composables para lógica reutilizable
- Variables CSS para estilos dinámicos
- Metodología BEM para clases
❌ Prohibido
Section titled “❌ Prohibido”console.log()en producción- SVG inline (usar
<Icon>) - Magic strings/numbers
- Imports de Vue (se autoimportan)
- Comentarios HTML en templates
📄 Páginas y Layouts
Section titled “📄 Páginas y Layouts”Estructura de Páginas
Section titled “Estructura de Páginas”pages/├── index.vue # Página principal├── cursos/│ ├── index.vue # Lista de cursos│ └── [slug].vue # Detalle de curso├── series/│ ├── index.vue│ └── [slug]/│ ├── index.vue # Detalle de serie│ └── [episode].vue # Episodio específico└── [otras-paginas].vueLayouts Dinámicos
Section titled “Layouts Dinámicos”Los layouts se organizan por vertical:
layouts/├── default.vue # Layout por defecto└── pages/ ├── login.vue # Layout para login ├── inspiria/ # Layouts específicos de Inspiria │ ├── home.vue │ └── course.vue └── traumatology/ # Layouts específicos de Traumatología └── home.vuePáginas Exclusivas
Section titled “Páginas Exclusivas”Las páginas exclusivas se definen en la configuración del vertical:
export const INSPIRIA: InspiriaVertical = { // ...otras propiedades exclusivePages: [ "plan-site", "dental-procedures-simulator", "videos-3d-formacion-dental", "kit-digital", "inspiria-talks", "who-we-are", "osteocom", ],};Creación de Páginas
Section titled “Creación de Páginas”Página Simple
Section titled “Página Simple”<script setup lang="ts">// Configuración de SEOuseSeoMeta({ title: "Título de la página", description: "Descripción de la página",});
// Lógica de la páginaconst contenido = await useServices<ContenidoData>("getContenido");</script>
<template> <div class="nueva-pagina"> <h1 class="nueva-pagina__title">{{ contenido.title }}</h1> <div class="nueva-pagina__content"> <!-- Contenido de la página --> </div> </div></template>
<style lang="scss" scoped>.nueva-pagina { padding: var(--s-spacing-large);
&__title { font-size: var(--s-font-heading-h1); margin: 0 0 var(--s-spacing-medium) 0; }
&__content { color: var(--c-text-primary); }}</style>Página Dinámica
Section titled “Página Dinámica”<script setup lang="ts">const route = useRoute();const { slug } = route.params;
const producto = await useServices<DetailProduct, FilterParams>( "getProductBySlug", { slug: route.params.slug, vertical: visibleVertical.name, });
if (!producto) { throw createError({ statusCode: 404, statusMessage: "Producto no encontrado", });}</script>
<template> <div class="producto-detalle"> <h1>{{ producto.title }}</h1> <!-- Más contenido --> </div></template>🎨 Uso de Iconos con Nuxt Icons
Section titled “🎨 Uso de Iconos con Nuxt Icons”Configuración
Section titled “Configuración”El proyecto usa @nuxt/icon que está configurado en nuxt.config.ts:
export default defineNuxtConfig({ modules: [ "@nuxt/icon", // ...otros módulos ],});Constantes de Iconos
Section titled “Constantes de Iconos”Los iconos se definen en /app/constants/icons.ts:
export const ICONS = { action: { close: "heroicons:x-mark", loading: "svg-spinners:3-dots-fade", search: "heroicons:magnifying-glass", }, social: { facebook: "fa6-brands:facebook", instagram: "fa6-brands:instagram", linkedin: "fa6-brands:linkedin", }, navigation: { arrow_down: "heroicons:chevron-down", arrow_up: "heroicons:chevron-up", arrow_left: "heroicons:chevron-left", arrow_right: "heroicons:chevron-right", },} as const;Uso de Iconos
Section titled “Uso de Iconos”En Componentes
Section titled “En Componentes”<template> <!-- ❌ Prohibido: SVG inline --> <svg>...</svg>
<!-- ✅ Correcto: Usar Icon component --> <Icon :name="ICONS.action.close" class="close-icon" /> <Icon :name="ICONS.social.facebook" /> <Icon name="heroicons:home" /></template>
<script setup lang="ts">import { ICONS } from "~/constants/icons";</script>
<style lang="scss" scoped>.close-icon { width: 2em; height: 2em; color: var(--c-primary);}</style>En Composables
Section titled “En Composables”import { ICONS } from "~/constants/icons";
export const useNotifications = () => { const showSuccess = () => { // Usar icono en notificación notification.value = { icon: ICONS.action.success, message: "Operación exitosa", }; };};Librerías de Iconos Disponibles
Section titled “Librerías de Iconos Disponibles”- Heroicons:
heroicons:icon-name - Font Awesome:
fa6-brands:icon-name,fa6-solid:icon-name - SVG Spinners:
svg-spinners:icon-name - Material Design:
material-symbols:icon-name
🏗️ Cómo Crear un Vertical Nuevo
Section titled “🏗️ Cómo Crear un Vertical Nuevo”Paso 1: Crear la Configuración del Vertical
Section titled “Paso 1: Crear la Configuración del Vertical”- Crear archivo de configuración:
import type { NuevoVertical } from "~/interfaces/verticals";
export const NUEVO_VERTICAL: NuevoVertical = { name: "nuevo-vertical", subdomain: "nuevovertical", i18nDirectory: "nuevo-vertical", primaryColor: "#ff6b6b", secondaryColor: "#4ecdc4", productionURL: "https://nuevovertical.alebateducation.com/", productionApi: "https://multiverticales.alebateducation.com/", developmentApi: "http://localhost:1337", favicon: "/favicon-nuevo-vertical.png", verticalLogo: "/logo-nuevo-vertical.svg", exclusivePages: ["pagina-exclusiva-1", "pagina-exclusiva-2"], strapiService: "nuevo-vertical", services: { // Implementar servicios necesarios getHomePage: async () => await getHomePageService(), // ...más servicios }, localeDirectory: "nuevo-vertical", layoutDirectory: "nuevo-vertical", analyticsCode: "G-XXXXXXXXX",};Paso 2: Actualizar Interfaces
Section titled “Paso 2: Actualizar Interfaces”export interface NuevoVertical extends Omit<Verticals, "services"> { services: CommonServices & { // Servicios específicos del nuevo vertical getServicioEspecifico: FunctionService<TipoRespuesta, Parametros>; };}Paso 3: Crear Servicios Específicos
Section titled “Paso 3: Crear Servicios Específicos”export const getHomePageService = async () => { const { response } = await httpClient.get<HomePageData>({ resource: "nuevo-vertical-home-page", params: [{ name: "populate", value: "deep" }], });
return response;};Paso 4: Configurar Traducciones
Section titled “Paso 4: Configurar Traducciones”- Crear directorios de traducciones:
app/locales/nuevo-vertical/├── es/│ ├── common.json│ ├── homepage.json│ └── navigation.json├── en/│ └── [mismos archivos]└── pt-BR/ └── [mismos archivos]- Crear interfaces de localización:
export interface NuevoVerticalHomeLocales { hero: { title: string; subtitle: string; }; features: { title: string; items: FeatureItem[]; };}Paso 5: Registrar el Vertical
Section titled “Paso 5: Registrar el Vertical”import { NUEVO_VERTICAL } from "~/constants/verticals/nuevoVertical";
export const VERTICAL_LIST = [ INSPIRIA, MENTAL_HEALTH, TRAUMATOLOGY, EMERGENCY, ONCOLOGY, PHARMA, NUEVO_VERTICAL, // ← Agregar aquí];Paso 6: Crear Layouts Específicos (Opcional)
Section titled “Paso 6: Crear Layouts Específicos (Opcional)”<template> <div class="nuevo-vertical-layout"> <NavbarNuevoVertical /> <main class="nuevo-vertical-layout__main"> <slot /> </main> <FooterNuevoVertical /> </div></template>Paso 7: Agregar Recursos Estáticos
Section titled “Paso 7: Agregar Recursos Estáticos”- Favicon:
/public/favicon-nuevo-vertical.png - Logo:
/public/logo-nuevo-vertical.svg - Imágenes específicas en
/app/assets/images/
🚀 Cómo Iniciar el Proyecto
Section titled “🚀 Cómo Iniciar el Proyecto”Requisitos Previos
Section titled “Requisitos Previos”- Node.js v18 o superior
- pnpm (gestor de paquetes)
- Git
Instalación
Section titled “Instalación”- Clonar el repositorio:
git clone https://github.com/Alebat-Education/verticals-next-generation-front.gitcd verticals-next-generation-front- Instalar dependencias:
pnpm install- Configurar variables de entorno:
Comandos Disponibles
Section titled “Comandos Disponibles”# Desarrollopnpm dev # Iniciar servidor de desarrollopnpm dev -o # Iniciar y abrir en navegador
# Buildpnpm build # Construir para producciónpnpm generate # Generar sitio estáticopnpm preview # Vista previa de build
# Testingpnpm test # Ejecutar testspnpm vitest # Ejecutar tests una vez
# Lintingpnpm lint # Verificar códigopnpm lint:fix # Corregir errores automáticamentepnpm styles # Verificar estilospnpm styles:fix # Corregir estilos
# Otrospnpm format # Formatear código con Prettierpnpm analyze # Análisis de tipos TypeScriptpnpm clean # Limpiar cache de NuxtEstructura de URLs
Section titled “Estructura de URLs”- Desarrollo:
http://localhost:3000 - Producción: Según configuración del vertical
- Inspiria:
https://pre.inspiriadental.com/
- Inspiria:
✅ Mejores Prácticas
Section titled “✅ Mejores Prácticas”Código TypeScript
Section titled “Código TypeScript”✅ Hacer
Section titled “✅ Hacer”// Usar tipos explícitosinterface UserData { id: number; name: string; email: string;}
// Constantes tipadasconst USER_ROLES = { ADMIN: "admin", USER: "user",} as const;
// Props con tiposinterface Props { title: string; variant?: "primary" | "secondary";}const props = withDefaults(defineProps<Props>(), { variant: "primary",});❌ Evitar
Section titled “❌ Evitar”// Magic stringsif (user.role === "admin") {}
// Tipos anyconst data: any = await fetch();
// Variables sin usoimport { ref, computed, watch } from "vue"; // ← watch no se usaComponentes Vue
Section titled “Componentes Vue”✅ Hacer
Section titled “✅ Hacer”<script setup lang="ts">// Interfaces clarasinterface Props { items: string[]; isLoading?: boolean;}
// Composables para lógicaconst { isVisible, toggle } = useModal();
// Cleanup apropiadoonUnmounted(() => { cleanup();});</script>
<template> <div class="component"> <Icon :name="ICONS.action.loading" v-if="isLoading" /> <div class="component__content" v-else> <slot /> </div> </div></template>❌ Evitar
Section titled “❌ Evitar”<template> <!-- Comentarios HTML --> <!-- No hacer esto -->
<!-- SVG inline --> <svg><path d="..."/></svg>
<!-- Magic strings --> <div class="red-button"></template>
<script setup lang="ts">// Imports innecesariosimport { ref } from 'vue'
// Console.logconsole.log('debug info')</script>Estilos SCSS
Section titled “Estilos SCSS”🚨 REGLAS CRÍTICAS - OBLIGATORIO CUMPLIR
Section titled “🚨 REGLAS CRÍTICAS - OBLIGATORIO CUMPLIR”Prohibido Usar Unidades Píxeles (px)
Section titled “Prohibido Usar Unidades Píxeles (px)”// ❌ PROHIBIDO - Usar px directamente.componente { font-size: 24px; // ¡ERROR! margin: 10px; // ¡ERROR! padding: 16px 20px; // ¡ERROR! border-radius: 8px; // ¡ERROR!}
// ✅ CORRECTO - Usar variables CSS predefinidas.componente { font-size: var(--s-font-h3); // 2rem margin: var(--s-spacing-small); // Usar spacing definido padding: var(--s-padding-medium) var(--s-padding-large); border-radius: var(--s-border-radius-card); // 0.625rem}Variables CSS Obligatorias
Section titled “Variables CSS Obligatorias”TODAS las propiedades deben usar las variables predefinidas del sistema de diseño:
// Variables de Color (--c-*)--c-black: #000000 --c-white: #ffffff --c-graphite: #1a1a1a --c-light-graphite: #f0f0f0 --c-grey-light: #f0f0f0 --c-grey-medium: #707070 --c-primary // Color primario del vertical (dinámico) --c-secondary // Color secundario del vertical (dinámico) // Variables de Tipografía (--f-*) --f-font-thin: 'neulis-sans-thin' --f-font-light: 'neulis-sans-light' --f-font-regular: 'neulis-sans-regular' --f-font-medium: 'neulis-sans-medium' --f-font-semiBold: 'neulis-sans-semi-bold' --f-font-bold: 'neulis-sans-bold' // Variables de Tamaños (--s-*) --s-font-h1: 3rem // Títulos principales --s-font-h2: 3rem // Subtítulos grandes --s-font-h3: 2rem // Subtítulos medianos --s-font-h4: 1.5rem // Subtítulos pequeños --s-font-p: 1rem // Texto párrafo --s-font-small: 0.875rem // Texto pequeño --s-border-radius-card: 0.625rem // Border radius para cards --s-border-radius-button: 0.25rem // Border radius para botones --s-margin-blocks: 8rem // Margen entre bloques --s-padding-lateral: 8rem // Padding lateral estándar // Variables de Transición (--t-*) --t-transition-button: 0.3s; // Transiciones de botonesUso Correcto de Variables
Section titled “Uso Correcto de Variables”// ✅ CORRECTO - Componente siguiendo el sistema.hero-section { padding: var(--s-margin-blocks) var(--s-padding-lateral); background-color: var(--c-graphite);
@include responsive() { padding: var(--s-margin-blocks) var(--s-padding-lateral-mobile); }
&__title { font-family: var(--f-font-bold); font-size: var(--s-font-h1); color: var(--c-white); margin: 0 0 var(--s-spacing-medium) 0;
@include responsive() { font-size: var(--s-font-h1-mobile); } }
&__button { font-family: var(--f-font-medium); font-size: var(--s-font-cta); padding: var(--s-spacing-small) var(--s-spacing-medium); border-radius: var(--s-border-radius-button); transition: all var(--t-transition-button); background-color: var(--c-primary);
&:hover { transform: translateY(-2px); } }}Mixins Obligatorios
Section titled “Mixins Obligatorios”SIEMPRE usar los mixins predefinidos en lugar de CSS repetitivo:
// 🔧 Mixins Disponibles en /app/assets/styles/mixin.scss
// 1. Responsive Design@include responsive($size: 64rem) { // Estilos para pantallas menores a 64rem}
// 2. Flexbox@include flex( $direction: row, $align: center, $justify: center, $wrap: nowrap, $gap: 0);
// Ejemplos de uso:@include flex(column, flex-start, space-between, nowrap, 1rem);@include flex(row, center, flex-end);
// 3. Skeleton Loading@include skeleton();
// 4. Loader Animation@include loader();Ejemplo de uso correcto de mixins:
.product-grid { @include flex(row, flex-start, flex-start, wrap, var(--s-spacing-medium)); padding: var(--s-padding-lateral);
@include responsive() { @include flex(column, center, center, nowrap, var(--s-spacing-small)); padding: var(--s-padding-lateral-mobile); }
&__item { background-color: var(--c-white); border-radius: var(--s-border-radius-card); overflow: hidden; transition: transform var(--t-transition-button);
&--loading { position: relative;
&::before { @include skeleton(); content: ""; } }
&:hover { transform: translateY(-4px); } }}🔒 ARCHIVOS PROTEGIDOS - PROHIBIDO MODIFICAR
Section titled “🔒 ARCHIVOS PROTEGIDOS - PROHIBIDO MODIFICAR”Los siguientes archivos NO PUEDEN ser modificados sin autorización del responsable del proyecto:
app/assets/styles/├── variables.css # 🔒 Sistema de variables CSS├── mixin.scss # 🔒 Mixins y funciones SCSS├── reset.scss # 🔒 Reset CSS base├── fonts.css # 🔒 Definición de fuentes└── [otros archivos] # 🔒 Estilos globales del sistema⚠️ IMPORTANTE: Cualquier cambio a estos archivos debe ser:
- Consultado con el responsable del proyecto
- Aprobado por el equipo de diseño
- Documentado en el sistema de variables
- Probado en todos los verticales
✅ Hacer - Buenas Prácticas
Section titled “✅ Hacer - Buenas Prácticas”.component { // 1. Usar variables CSS para todo color: var(--c-text-primary); background-color: var(--c-background-secondary); font-family: var(--f-font-regular); font-size: var(--s-font-p);
// 2. Spacing correcto con margin y padding abreviados margin: var(--s-spacing-medium) 0; // top/bottom medium, left/right 0 padding: var(--s-spacing-small) var(--s-spacing-medium); // vertical small, horizontal medium
// 3. Usar mixins para layouts @include flex(column, flex-start, flex-start, nowrap, var(--s-spacing-small));
// 4. Responsive con mixin @include responsive() { font-size: var(--s-font-small); margin: var(--s-spacing-small) 0; // Reducir spacing en mobile padding: var(--s-spacing-small); // Padding uniforme en mobile @include flex(row, center, center); }
// 5. BEM obligatorio &__element { border-radius: var(--s-border-radius-card); margin: 0 0 var(--s-spacing-small) 0; // Solo margin-bottom
&--modifier { background-color: var(--c-primary); padding: var(--s-spacing-medium) var(--s-spacing-large) var( --s-spacing-small ) var(--s-spacing-large); // top, right, bottom, left } }
// 6. Estados con variables &:hover { transform: translateY(-2px); transition: all var(--t-transition-button); }}❌ Evitar - Errores Críticos
Section titled “❌ Evitar - Errores Críticos”// ❌ CRÍTICO: Usar px directamente.component { font-size: 16px; // ¡ERROR! margin: 10px 20px; // ¡ERROR! border-radius: 8px; // ¡ERROR!}
// ❌ CRÍTICO: Colores hardcodeados.component { color: #333333; // ¡ERROR! background: #ffffff; // ¡ERROR! border: 1px solid red; // ¡ERROR!}
// ❌ CRÍTICO: Propiedades individuales de spacing.component { margin-top: 16px; // ¡ERROR! Usar margin: 16px 0 0 0; margin-bottom: 20px; // ¡ERROR! Usar margin: 0 0 20px 0; padding-left: 10px; // ¡ERROR! Usar padding: 0 0 0 10px; padding-right: 15px; // ¡ERROR! Usar padding: 0 15px 0 0; padding-top: 5px; // ¡ERROR! Usar padding: 5px 0 0 0;}
// ❌ CRÍTICO: Sin BEM.component .title { // ¡ERROR! font-size: 24px; // ¡ERROR!}
// ❌ CRÍTICO: CSS repetitivo sin mixins.component { display: flex; // ¡ERROR! Usar @include flex() flex-direction: column; // ¡ERROR! align-items: center; // ¡ERROR! justify-content: center; // ¡ERROR!}
// ❌ CRÍTICO: Responsive sin mixin@media (max-width: 768px) { // ¡ERROR! Usar @include responsive() .component { font-size: 14px; // ¡ERROR! }}
// ❌ CRÍTICO: Magic numbers.component { z-index: 999; // ¡ERROR! Sin justificación animation-duration: 0.5s; // ¡ERROR! Usar variable}🎨 Sistema de Variables por Vertical
Section titled “🎨 Sistema de Variables por Vertical”Cada vertical tiene variables dinámicas que se cargan automáticamente:
// Variables que cambian automáticamente por vertical.vertical-component { background-color: var(--c-primary); // Color primario del vertical activo border-left: 0.125em solid var(--c-secondary); // Color secundario del vertical activo}
// Inspiria: --c-primary será #92ffac// Traumatología: --c-primary será otro color// Salud Mental: --c-primary será otro color📏 Espaciado y Proporciones
Section titled “📏 Espaciado y Proporciones”// Sistema de espaciado consistente (usar variables específicas cuando estén disponibles).component { // Espaciado pequeño margin: 0 0 var(--s-spacing-small) 0; // o usar rem equivalente
// Espaciado medio padding: var(--s-spacing-medium); // o usar rem equivalente
// Espaciado grande margin: var(--s-spacing-large) 0 0 0; // o usar rem equivalente
// Para bloques principales padding: var(--s-margin-blocks) var(--s-padding-lateral);
@include responsive() { padding: var(--s-margin-blocks) var(--s-padding-lateral-mobile); }}🔍 Verificación de Cumplimiento
Section titled “🔍 Verificación de Cumplimiento”# Verificar cumplimiento de estilospnpm styles # Linting de SCSSpnpm styles:fix # Auto-corrección cuando sea posible
# Búsquedas para detectar errores:# - Buscar "px" en archivos .scss/.vue# - Buscar colores hexadecimales (#)# - Buscar @media sin mixin responsive# - Buscar display: flex sin mixin📝 Checklist de Revisión SCSS
Section titled “📝 Checklist de Revisión SCSS”Antes de hacer commit, verificar:
- ✅ No hay unidades px en ningún lugar
- ✅ Todas las propiedades usan variables CSS (—c-, —f-, —s-, —t-)
- ✅ Propiedades abreviadas para margin y padding (no margin-top, padding-left, etc.)
- ✅ Metodología BEM aplicada correctamente
- ✅ Mixins utilizados para responsive, flex, skeleton, loader
- ✅ No hay colores hardcodeados (#hex, red, blue, etc.)
- ✅ No hay magic numbers sin justificación
- ✅ Archivos del sistema no modificados
- ✅ Máximo 3 niveles de anidación SCSS
- ✅ Transiciones y animaciones usando variables del sistema
Servicios y APIs
Section titled “Servicios y APIs”✅ Hacer
Section titled “✅ Hacer”// Tipado fuerteinterface ApiResponse<T> { data: T; status: "success" | "error"; message?: string;}
// Manejo de erroresexport const getUsers = async (): Promise<User[]> => { try { const { response } = await httpClient.get<User[]>({ resource: "users", params: [{ name: "populate", value: "*" }], }); return response; } catch (error) { throw createError({ statusCode: 500, statusMessage: "Error al obtener usuarios", }); }};❌ Evitar
Section titled “❌ Evitar”// Sin tiposexport const getUsers = async () => { const data = await fetch("/api/users"); return data.json();};
// Sin manejo de erroresexport const createUser = async (userData) => { const response = await httpClient.post({ resource: "users", body: userData }); return response; // ¿Qué pasa si falla?};Composables
Section titled “Composables”✅ Hacer
Section titled “✅ Hacer”export const useApiData = <T>(endpoint: string) => { const data = ref<T | null>(null); const isLoading = ref(false); const error = ref<string | null>(null);
const fetchData = async () => { isLoading.value = true; error.value = null;
try { const response = await httpClient.get<T>({ resource: endpoint }); data.value = response.response; } catch (err) { error.value = "Error al cargar datos"; } finally { isLoading.value = false; } };
return { data: readonly(data), isLoading: readonly(isLoading), error: readonly(error), fetchData, };};Internacionalización
Section titled “Internacionalización”✅ Hacer
Section titled “✅ Hacer”// Claves estructuradas{ "homepage": { "hero": { "title": "Bienvenido", "subtitle": "Aprende con nosotros" }, "features": { "title": "Características", "items": [ "Fácil de usar", "Contenido de calidad" ] } }}
// Uso con parámetrosconst message = t('user.welcome', { name: userName })❌ Evitar
Section titled “❌ Evitar”// Claves planas sin estructura{ "welcome": "Bienvenido", "homepage_title": "Título", "button_click": "Hacer clic"}
// Textos hardcodeados<h1>Bienvenido a nuestra plataforma</h1>🔧 Troubleshooting (identificar, analizar y resolver problemas)
Section titled “🔧 Troubleshooting (identificar, analizar y resolver problemas)”Problemas Comunes
Section titled “Problemas Comunes”Error de Tipos TypeScript
Section titled “Error de Tipos TypeScript”# Regenerar tipospnpm postinstallpnpm analyzeError de Importación de Componentes
Section titled “Error de Importación de Componentes”// Verificar auto-imports en nuxt.config.tsexport default defineNuxtConfig({ imports: { dirs: ["core/globals"], },});Error de Variables CSS
Section titled “Error de Variables CSS”// Asegurarse de que el mixin esté disponible@use "@/assets/styles/mixin.scss" as *;Error de Rutas i18n
Section titled “Error de Rutas i18n”// Verificar configuración en config/i18n/pages.tsexport const pages = { about: { es: "/acerca-de", en: "/about", },};Comandos de Diagnóstico
Section titled “Comandos de Diagnóstico”# Información del entornopnpm info
# Limpiar y reinstalarpnpm cleanrm -rf node_modules pnpm-lock.yamlpnpm install
# Verificar configuraciónpnpm analyze📚 Recursos Adicionales
Section titled “📚 Recursos Adicionales”Documentación Oficial
Section titled “Documentación Oficial”Herramientas de Desarrollo
Section titled “Herramientas de Desarrollo”Guías de Estilo
Section titled “Guías de Estilo”🤝 Contribución
Section titled “🤝 Contribución”Flujo de Trabajo
Section titled “Flujo de Trabajo”- Crear rama de feature:
git checkout -b feature/nueva-funcionalidad - Desarrollar siguiendo las mejores prácticas
- Commits siguiendo conventional commits
- Push y crear Pull Request
- Review por el equipo
- Merge a main
Conventional Commits
Section titled “Conventional Commits”feat: agregar nueva funcionalidadfix: corregir error en componentedocs: actualizar documentaciónstyle: formatear códigorefactor: refactorizar serviciotest: agregar tests unitarios¡Bienvenido/a al equipo! 🎉