Skip to content

Como funciona el proyecto

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.

  • Node.js v18 o superior
  • pnpm (gestor de paquetes)
  • Git
  1. Clonar el repositorio:
Terminal window
git clone https://github.com/Alebat-Education/verticals-next-generation-front.git
cd verticals-next-generation-front
  1. Instalar dependencias:
Terminal window
pnpm install
  1. Configurar variables de entorno:

Cada vertical está configurado para funcionar en un puerto específico, permitiendo el desarrollo independiente o conjunto:

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

Este sistema permite:

  1. Desarrollo paralelo - Diferentes equipos pueden trabajar en verticales distintos simultáneamente
  2. Testing independiente - Probar cambios en un vertical sin afectar otros
  3. Debugging específico - Identificar problemas en verticales concretos
  4. Optimización de recursos - Solo ejecutar los verticales necesarios durante el desarrollo
Terminal window
# Ejecutar solo Inspiria en puerto 3000
pnpm dev
# Ejecutar con apertura automática del navegador
pnpm dev -o
Terminal window
# Ejecutar "Salud Mental" (puerto 3001)
pnpm run dev -- --port 3001
# Ejecutar "Traumatología" (puerto 3002)
pnpm run dev -- --port 3002
Terminal window
# Build y Deploy
pnpm build # Construir para producción
pnpm generate # Generar sitio estático
pnpm preview # Vista previa de build
# Testing y Calidad
pnpm test # Ejecutar tests
pnpm vitest # Ejecutar tests una vez
pnpm lint # Verificar código
pnpm lint:fix # Corregir errores automáticamente
pnpm styles # Verificar estilos SCSS
pnpm styles:fix # Corregir estilos
# Utilidades
pnpm format # Formatear código con Prettier
pnpm analyze # Análisis de tipos TypeScript
pnpm clean # Limpiar cache de Nuxt
  • Base: http://localhost:3000 (puerto variable según 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”

⚠️ 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
.vscode/settings.json
{
"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

� Reglas Críticas - Cumplimiento Obligatorio

Section titled “� Reglas Críticas - Cumplimiento Obligatorio”

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 uno
if (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:

constants/userRoles.ts
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
// 1. Usar tipos explícitos para mejor documentación y seguridad
interface UserData {
id: number;
name: string;
email: string;
role: (typeof USER_ROLES)[keyof typeof USER_ROLES];
}
// 2. Constantes tipadas - evitan magic strings
const USER_ROLES = {
ADMIN: "admin",
USER: "user",
} as const;
// 3. Props con tipos específicos - mejor IntelliSense y detección de errores
interface Props {
title: string;
variant?: "primary" | "secondary" | "danger";
isDisabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
variant: "primary",
isDisabled: false,
});
// 4. Principios SOLID aplicados
class 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:

// ❌ Magic strings - valor hardcodeado sin constante
if (user.role === "admin") {
} // ¿De dónde sale 'admin'? ¿Es el único valor válido?
// ❌ Tipos any - pierdes toda la verificación de TypeScript
const data: any = await fetch(); // ¿Qué propiedades tiene? ¿Cómo lo uso?
// ❌ Variables sin uso - código muerto que confunde
import { ref, computed, watch } from "vue"; // ← 'watch' importado pero no usado
// ❌ var - comportamiento impredecible con hoisting y scope
var userName = "Juan"; // Usar const o let
// ❌ console.log en producción - información sensible expuesta
console.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 sutiles
  • console.log: Expone información en producción, afecta rendimiento
<script setup lang="ts">
// 1. Interfaces claras para props - documentación automática
interface Props {
items: string[];
isLoading?: boolean;
variant?: "compact" | "expanded";
}
const props = withDefaults(defineProps<Props>(), {
isLoading: false,
variant: "compact",
});
// 2. Composables para lógica reutilizable - Single Responsibility
const { isVisible, open, close } = useModal();
const { data, isPending, error } = useApiData<UserData>("users");
// 3. Cleanup apropiado - evita memory leaks
onUnmounted(() => {
close(); // Cerrar modales
clearInterval(intervalId); // Limpiar timers
removeEventListener("resize", handler); // Quitar listeners
});
// 4. Constantes para iconos - evita SVG inline
import { 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:

<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 autoimporta
import { ref } from 'vue'
// ❌ Props sin tipos - no hay verificación
const props = defineProps(['title', 'items'])
// ❌ Console.log - información expuesta
console.log('Component mounted')
// ❌ Sin cleanup - memory leaks potenciales
const timer = setInterval(() => {
// lógica repetitiva
}, 1000) // ¿Se limpia al desmontar?
</script>

🚨 REGLAS CRÍTICAS - OBLIGATORIO CUMPLIR

Section titled “🚨 REGLAS CRÍTICAS - OBLIGATORIO CUMPLIR”

❌ ¿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:

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
}

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:

.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);
}
}
}
}
// ❌ 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
}
}

Es fundamental entender cuándo usar cada uno para mantener la arquitectura del proyecto:

¿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 correcto
export 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 correcto
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;
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,
};
};

¿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 correcto
export 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 correcto
export 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,
};
}
}
utils/badExample.ts
// ❌ ERROR: Usar ref en utils
export const createCounter = () => {
const count = ref(0); // ¡ERROR! ref es para composables
return count;
};
// ❌ ERROR: Lógica pura en composables
// composables/badExample.ts
export 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.ts
export const capitalize = (str: string): string => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
  • ¿Necesita reactividad o lifecycle?composables/
  • ¿Es función pura sin efectos secundarios?utils/
  • Nuxt 4 + Vue 3 con Composition API
  • TypeScript para tipado estático
  • SCSS para estilos
  • SPA Mode (SSR deshabilitado)
  • @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
  • ESLint - Linting de código
  • Prettier - Formateo de código
  • Stylelint - Linting de estilos
  • Husky - Git hooks
  • pnpm - Gestor de paquetes

El proyecto utiliza una arquitectura multi-vertical donde:

  1. Base común: Funcionalidades compartidas entre verticales
  2. Configuración específica: Cada vertical tiene su configuración única
  3. Servicios modulares: APIs específicas para cada vertical
  4. Layouts dinámicos: Layouts que se adaptan según el vertical activo
  • 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”

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.vue
  • views/: Si es específico de una vista → views/homepage/HeroSection.vue
  • layouts/: Si es estructura de página → layouts/navbar/Navbar.vue

¿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)

¿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 cursos

Uso:

pages/series/index.vue
<script setup lang="ts">
import { chooseLayoutPage } from "~/core/plugins";
// ✅ Elige automáticamente el layout correcto
const layout = chooseLayoutPage({ page: "series" });
</script>
<template>
<NuxtLayout :name="layout">
<!-- Contenido específico de la página -->
</NuxtLayout>
</template>
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:

  1. Vertical específico: layouts/pages/inspiria/home.vue
  2. Common/Shared: layouts/pages/home.vue
  3. Default: layouts/default.vue

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

pages/mi-nueva-pagina.vue
<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

Terminal window
pnpm dev # Nuxt necesita detectar los nuevos archivos

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.

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.ts
export 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.ts
export 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.ts
export type LanguageCode = "es" | "en" | "pt-BR";
export type AlertType = "success" | "error" | "warning" | "info";
export type VerticalName = "inspiria" | "traumatologia" | "salud mental";
// ✅ types/pages.ts
export type LayoutPage = "home" | "login" | "series" | "courses" | "experts";
// ✅ types/forms.ts
export type LoginData = {
email: string;
password_not_empty: string;
youre_a_bot?: string;
};
  • ¿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)

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”

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

app/middleware/auth.ts
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?

pages/mi-cuenta/index.vue
<script setup lang="ts">
// Aplicar middleware a esta página
definePageMeta({
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

app/middleware/exclusive-pages.ts
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

app/middleware/language.ts
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

app/middleware/guest.ts
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:

pages/login.vue
<script setup lang="ts">
definePageMeta({
middleware: "guest", // ← Solo usuarios NO autenticados
});
</script>

Se ejecuta en todas las páginas automáticamente:

nuxt.config.ts
export default defineNuxtConfig({
router: {
middleware: ["language"], // ← Se ejecuta siempre
},
});

Se ejecuta solo en páginas específicas:

<script setup lang="ts">
definePageMeta({
middleware: "auth", // ← Solo en esta página
});
</script>

Se ejecuta en todas las páginas que usen ese layout:

layouts/dashboard.vue
<script setup lang="ts">
definePageMeta({
middleware: "auth", // ← Todas las páginas con layout="dashboard"
});
</script>
app/middleware/admin.ts
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",
});
}
});
pages/admin/dashboard.vue
<script setup lang="ts">
definePageMeta({
middleware: "admin", // ← Solo administradores
});
</script>

📊 Middleware con Parámetros Dinámicos

Section titled “📊 Middleware con Parámetros Dinámicos”
app/middleware/role.ts
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>
pages/instructores/[id]/perfil.vue
<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>
pages/admin/index.vue
<script setup lang="ts">
definePageMeta({
middleware: ["auth", "admin"], // ← Autenticado + administrador
layout: "admin",
});
</script>
pages/nosotros.vue
<script setup lang="ts">
definePageMeta({
middleware: "language", // ← Solo configuración de idioma
});
</script>
app/middleware/subscription.ts
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",
});
}
});
// 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,
});
}
});

✅ Antes de crear middleware:

  • ¿Es lógica que debe ejecutarse en múltiples páginas?
  • ¿Puede resolverse mejor con un composable?
  • ¿Necesita acceso a to y from routes?

✅ 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”

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)
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)

Propósito: Manejar el estado de autenticación, login, logout y tokens

app/stores/auth.ts
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,
};
});

Propósito: Gestionar datos del perfil, preferencias y configuraciones del usuario

app/stores/user.ts
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,
};
});

Propósito: Gestionar idioma actual, traducciones disponibles y cambios de idioma

app/stores/language.ts
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,
};
});

Propósito: Controlar estado de modales de forma centralizada

app/stores/modal.ts
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,
};
});

Propósito: Ejemplo de store para e-commerce/compras

app/stores/cart.ts
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,
};
});
<script setup lang="ts">
// Importar y usar stores
const authStore = useAuthStore();
const userStore = useUserStore();
// Acceso reactivo al estado
const 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>
<script setup lang="ts">
// Para destructurar estado manteniendo reactividad
const authStore = useAuthStore();
const { isAuthenticated, loginLoading } = storeToRefs(authStore);
const { login, logout } = authStore; // Actions no necesitan storeToRefs
// Uso con formularios
const 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>

Plugin para auto-persistir stores:

plugins/stores-persistence.client.ts
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();
}
});
tests/stores/auth.test.ts
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");
});
});

✅ 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

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.).

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",
};
  1. Inspiria - Odontología
  2. Traumatología - Medicina de traumatología
  3. Salud Mental - Psicología y psiquiatría
  4. Emergencias - Medicina de emergencia
  5. Oncología - Medicina oncológica
  6. Pharma - Farmacología

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”

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
app/locales/inspiria/es/homepage.json
{
"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

Paso 1: Crear la Interface TypeScript

/app/interfaces/locales/verticals/inspiria/loginForm.ts
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

/app/locales/inspiria/es/login.json
{
"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"
}
/app/locales/inspiria/en/login.json
{
"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íficas
const { 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

¿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és
const 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”
i18n/
├── es.json # Español global
├── en.json # Inglés global
└── pt-BR.json # Portugués global
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

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
};
<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 i18n
const { t } = useI18n();
// Específico del vertical
const { 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.


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
// Obtener datos estáticos de una página
const homePage = await useServices<HomePageData>("getHomePage");
const legalPages = await useServices<LegalPagesApi>("getLegalPages");

¿Qué está pasando aquí?

  1. useServices busca el servicio getHomePage en el vertical actual
  2. Lo ejecuta con el idioma del usuario (ej: español)
  3. Si falla o está incompleto, intenta en inglés como fallback
  4. Retorna los datos combinados y tipados
// Obtener datos dinámicos con filtros
const expertData = await useServices<Expert[], FilterParams>("getExperts", {
vertical: visibleVertical.name ?? "",
category: "odontologia",
});
// Obtener un producto específico por slug
const 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,
}
);
// /app/services/fetchApi.ts - El "motor" que hace las peticiones
class 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();
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.

/app/services/shared/users.ts
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
/app/interfaces/api/shared/users.ts
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 CommonServices
interface CommonServices {
// ... otros servicios existentes
getUser: FunctionService<User, { userId: string }>; // ← Añadir aquí
}
// /app/constants/verticals/inspiria.ts - Implementar en cada vertical
import { 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?

  1. getUser es GLOBAL → Se define en CommonServices → Está disponible en TODAS las verticales
  2. getSeries específico → Se define solo en la interfaz de Inspiria → Solo disponible en Inspiria
  3. Tipado garantizado → TypeScript verifica que todos los servicios estén implementados
<!-- En cualquier componente -->
<script setup lang="ts">
// Usar el servicio que acabamos de crear
const 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”

¿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;
}
};

Problema: Repetir la misma configuración de populate en múltiples servicios.

❌ Sin estructura reutilizable:

// En cada servicio repetimos lo mismo
const { data } = await find("products", {
populate: {
image: { fields: ["id", "url", "alternativeText"] },
experts: { fields: ["id", "name"] },
seo: { fields: ["title", "description"] },
},
});

✅ Con estructura reutilizable:

/app/constants/api-structures/commonStructure.ts
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
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 Pharma

Paso 1: Crear el archivo del servicio

/app/services/shared/miNuevoServicio.ts
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;
}
};

Paso 1: Crear el directorio del vertical (si no existe)

Terminal window
mkdir app/services/nuevo-vertical

Paso 2: Crear el archivo del servicio

/app/services/nuevo-vertical/homePageService.ts
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

/app/constants/verticals/nuevoVertical.ts
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;
},
},
};

Paso 4: Implementar en componentes

// En cualquier componente o página
const { data: miNuevosDatos } = await useServices<MiNuevoTipo[]>(
"getMiNuevoServicio"
);
const { data: homeData } = await useServices<NuevoVerticalHomeApi>(
"getHomePage"
);
// Con parámetros
const { data: datosEspecificos } = await useServices<
MiNuevoTipo[],
FilterParams
>("getMiNuevoServicio", {
vertical: visibleVertical.name,
});
// Manejo de errores consistente
export 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 predefinidas
populate: {
image: { fields: ['id', 'url', 'alternativeText'] },
seo: SEO,
experts: EXPERTS_STRUCTURE,
}
// Tipos específicos para populate
type ServicePopulateParams = Omit<TipoOriginal, 'relacion'> & {
relacion: TipoRelacionTransformado[]
}
// Sin manejo de errores
export const getServicio = async (params) => {
const { data } = await find('endpoint', params)
return data // ¿Qué pasa si falla?
}
// Populate hardcodeado repetitivo
populate: {
image: {
fields: ['id', 'url', 'alternativeText', 'width', 'height']
},
// Repetir esta estructura en cada servicio
}
// Sin tipos TypeScript
export const getServicio = async (params: any): Promise<any> => {
// ...
}

Para servicios que no usen Strapi:

/app/services/shared/servicioExterno.ts
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;
}
};

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;
}
}
}
<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 composables
const { 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>

Los composables siguen el patrón use[Funcionalidad]:

/app/composables/useModal.ts
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,
};
};
  • <script setup> con TypeScript
  • Props tipados con interfaces
  • Composables para lógica reutilizable
  • Variables CSS para estilos dinámicos
  • Metodología BEM para clases
  • console.log() en producción
  • SVG inline (usar <Icon>)
  • Magic strings/numbers
  • Imports de Vue (se autoimportan)
  • Comentarios HTML en templates

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].vue

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

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",
],
};
pages/nueva-pagina.vue
<script setup lang="ts">
// Configuración de SEO
useSeoMeta({
title: "Título de la página",
description: "Descripción de la página",
});
// Lógica de la página
const 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>
pages/productos/[slug].vue
<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>

El proyecto usa @nuxt/icon que está configurado en nuxt.config.ts:

export default defineNuxtConfig({
modules: [
"@nuxt/icon",
// ...otros módulos
],
});

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;
<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>
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",
};
};
};
  • 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

Paso 1: Crear la Configuración del Vertical

Section titled “Paso 1: Crear la Configuración del Vertical”
  1. Crear archivo de configuración:
/app/constants/verticals/nuevoVertical.ts
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",
};
/app/interfaces/verticals.ts
export interface NuevoVertical extends Omit<Verticals, "services"> {
services: CommonServices & {
// Servicios específicos del nuevo vertical
getServicioEspecifico: FunctionService<TipoRespuesta, Parametros>;
};
}
/app/services/nuevo-vertical/homePage.ts
export const getHomePageService = async () => {
const { response } = await httpClient.get<HomePageData>({
resource: "nuevo-vertical-home-page",
params: [{ name: "populate", value: "deep" }],
});
return response;
};
  1. Crear directorios de traducciones:
app/locales/nuevo-vertical/
├── es/
│ ├── common.json
│ ├── homepage.json
│ └── navigation.json
├── en/
│ └── [mismos archivos]
└── pt-BR/
└── [mismos archivos]
  1. Crear interfaces de localización:
/app/interfaces/locales/verticals/nuevo-vertical.ts
export interface NuevoVerticalHomeLocales {
hero: {
title: string;
subtitle: string;
};
features: {
title: string;
items: FeatureItem[];
};
}
/app/core/config.ts
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)”
/app/layouts/pages/nuevo-vertical/home.vue
<template>
<div class="nuevo-vertical-layout">
<NavbarNuevoVertical />
<main class="nuevo-vertical-layout__main">
<slot />
</main>
<FooterNuevoVertical />
</div>
</template>
  • Favicon: /public/favicon-nuevo-vertical.png
  • Logo: /public/logo-nuevo-vertical.svg
  • Imágenes específicas en /app/assets/images/

  • Node.js v18 o superior
  • pnpm (gestor de paquetes)
  • Git
  1. Clonar el repositorio:
Terminal window
git clone https://github.com/Alebat-Education/verticals-next-generation-front.git
cd verticals-next-generation-front
  1. Instalar dependencias:
Terminal window
pnpm install
  1. Configurar variables de entorno:
Terminal window
# Desarrollo
pnpm dev # Iniciar servidor de desarrollo
pnpm dev -o # Iniciar y abrir en navegador
# Build
pnpm build # Construir para producción
pnpm generate # Generar sitio estático
pnpm preview # Vista previa de build
# Testing
pnpm test # Ejecutar tests
pnpm vitest # Ejecutar tests una vez
# Linting
pnpm lint # Verificar código
pnpm lint:fix # Corregir errores automáticamente
pnpm styles # Verificar estilos
pnpm styles:fix # Corregir estilos
# Otros
pnpm format # Formatear código con Prettier
pnpm analyze # Análisis de tipos TypeScript
pnpm clean # Limpiar cache de Nuxt
  • Desarrollo: http://localhost:3000
  • Producción: Según configuración del vertical
    • Inspiria: https://pre.inspiriadental.com/

// Usar tipos explícitos
interface UserData {
id: number;
name: string;
email: string;
}
// Constantes tipadas
const USER_ROLES = {
ADMIN: "admin",
USER: "user",
} as const;
// Props con tipos
interface Props {
title: string;
variant?: "primary" | "secondary";
}
const props = withDefaults(defineProps<Props>(), {
variant: "primary",
});
// Magic strings
if (user.role === "admin") {
}
// Tipos any
const data: any = await fetch();
// Variables sin uso
import { ref, computed, watch } from "vue"; // ← watch no se usa
<script setup lang="ts">
// Interfaces claras
interface Props {
items: string[];
isLoading?: boolean;
}
// Composables para lógica
const { isVisible, toggle } = useModal();
// Cleanup apropiado
onUnmounted(() => {
cleanup();
});
</script>
<template>
<div class="component">
<Icon :name="ICONS.action.loading" v-if="isLoading" />
<div class="component__content" v-else>
<slot />
</div>
</div>
</template>
<template>
<!-- Comentarios HTML -->
<!-- No hacer esto -->
<!-- SVG inline -->
<svg><path d="..."/></svg>
<!-- Magic strings -->
<div class="red-button">
</template>
<script setup lang="ts">
// Imports innecesarios
import { ref } from 'vue'
// Console.log
console.log('debug info')
</script>

🚨 REGLAS CRÍTICAS - OBLIGATORIO CUMPLIR

Section titled “🚨 REGLAS CRÍTICAS - OBLIGATORIO CUMPLIR”
// ❌ 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
}

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 botones
// ✅ 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);
}
}
}

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:

  1. Consultado con el responsable del proyecto
  2. Aprobado por el equipo de diseño
  3. Documentado en el sistema de variables
  4. Probado en todos los verticales
.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);
}
}
// ❌ 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
}

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
// 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);
}
}
Terminal window
# Verificar cumplimiento de estilos
pnpm styles # Linting de SCSS
pnpm 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

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
// Tipado fuerte
interface ApiResponse<T> {
data: T;
status: "success" | "error";
message?: string;
}
// Manejo de errores
export 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",
});
}
};
// Sin tipos
export const getUsers = async () => {
const data = await fetch("/api/users");
return data.json();
};
// Sin manejo de errores
export const createUser = async (userData) => {
const response = await httpClient.post({ resource: "users", body: userData });
return response; // ¿Qué pasa si falla?
};
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,
};
};
// 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ámetros
const message = t('user.welcome', { name: userName })
// 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)”
Terminal window
# Regenerar tipos
pnpm postinstall
pnpm analyze
// Verificar auto-imports en nuxt.config.ts
export default defineNuxtConfig({
imports: {
dirs: ["core/globals"],
},
});
// Asegurarse de que el mixin esté disponible
@use "@/assets/styles/mixin.scss" as *;
// Verificar configuración en config/i18n/pages.ts
export const pages = {
about: {
es: "/acerca-de",
en: "/about",
},
};
Terminal window
# Información del entorno
pnpm info
# Limpiar y reinstalar
pnpm clean
rm -rf node_modules pnpm-lock.yaml
pnpm install
# Verificar configuración
pnpm analyze


  1. Crear rama de feature: git checkout -b feature/nueva-funcionalidad
  2. Desarrollar siguiendo las mejores prácticas
  3. Commits siguiendo conventional commits
  4. Push y crear Pull Request
  5. Review por el equipo
  6. Merge a main
Terminal window
feat: agregar nueva funcionalidad
fix: corregir error en componente
docs: actualizar documentación
style: formatear código
refactor: refactorizar servicio
test: agregar tests unitarios

¡Bienvenido/a al equipo! 🎉