diff --git a/AGENTS.md b/AGENTS.md index b0f2b702..481f1b5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,6 +179,14 @@ verify Render separately. - Do not revert user changes. Inspect `git status` first. - Prefer scoped changes matching existing Angular/Laravel patterns. +## Parallel AI coordination + +When Codex and Antigravity work at the same time, read +`docs/colaboracion-ias.md` and the two files under `docs/handoffs/` before +editing. Never work directly on `main`, never force-push a shared branch and do +not modify a file currently owned by the other agent. The account/profile block +belongs in the topbar; it is intentionally absent from the sidebar. + ## Docs to read by task - Overall project state: `docs/ai-project-context.md` diff --git a/ANTIGRAVITY.md b/ANTIGRAVITY.md new file mode 100644 index 00000000..dfb49369 --- /dev/null +++ b/ANTIGRAVITY.md @@ -0,0 +1,71 @@ +# Instrucciones operativas para Antigravity + +Este repositorio es producción real. Antes de editar, lee en este orden: + +1. `AGENTS.md`; +2. `docs/colaboracion-ias.md`; +3. `docs/handoffs/codex-to-antigravity.md`; +4. `docs/sistema-visual-portal-alumno.md` y `docs/portal-alumno.md` cuando el + alcance sea el portal estudiante. + +## Rama y límites + +- Trabaja únicamente en `feature/frontend-design-system` o en una rama + `antigravity/` creada desde el `origin/main` indicado en el handoff. +- Nunca hagas commit, merge, push o deploy directamente a `main`. +- No modifiques ramas `codex/*`. +- No mezcles más de un módulo funcional por commit. +- No uses `git reset --hard`, force-push ni descartes cambios ajenos. +- Si un archivo aparece modificado antes de empezar, detente sobre ese archivo + y regístralo en el handoff; no lo sobrescribas. + +## Contrato funcional + +- Angular presenta; Laravel autoriza y persiste. +- XP es progreso permanente y DAEMONS es saldo gastable. +- Un `401` invalida la sesión; permisos insuficientes deben responder `403`. +- No elimines rutas, botones, estados, IDs de tour, respuestas de error ni + acciones existentes para simplificar una pantalla. +- La ficha redundante de usuario se eliminó intencionalmente del sidebar. El + enlace de navegación `Mi perfil` permanece y el topbar conserva avatar, + cuenta, saldo, notificaciones y cierre de sesión. +- El hamburguesa móvil debe ser visible, tener al menos 40 px, abrir con un solo + toque y cerrar con overlay, Escape o navegación. +- No cambies contratos API, autenticación, telemetría o servicios `core` dentro + de una tarea puramente visual. + +## Contrato visual y responsive + +- Inter, colores sólidos, tarjetas blancas, borde ligero y radios de 12–16 px. +- Sin degradados, Outfit, glassmorphism, blur decorativo, saltos verticales de + tarjetas ni animación continua. +- Acciones táctiles de al menos 40 px y foco visible. +- Respeta `prefers-reduced-motion`. +- No inventes métricas, niveles, bloqueos, cursos ni estados. +- Implementa y revisa siempre: loading, datos, vacío, error y reintento. +- Verifica 1440×900, 1024×768, 390×844 y 360×800 sin scroll horizontal. + +## Entrega obligatoria + +Antes de pedir integración ejecuta: + +```powershell +cd frontend-angular +npm run check:architecture +npm run check:student-visual +npm run test:ci +npm run build +``` + +Después actualiza únicamente `docs/handoffs/antigravity-to-codex.md` con: + +- rama, commit base y commit entregado; +- objetivo y archivos cambiados; +- comportamiento preservado; +- evidencia de los comandos; +- capturas desktop/móvil o rutas para reproducirlas; +- riesgos, decisiones abiertas y archivos que Codex no debe tocar. + +Codex revisará el diff, lo integrará por cherry-pick en su rama y podrá +corregirlo. Un build verde no sustituye la revisión funcional, visual y de +accesibilidad. diff --git a/docs/README.md b/docs/README.md index 2d1565a2..c048c70f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,9 @@ a la tarea. | `gamificacion-xp-daemons.md` | Separación de XP y DAEMONS, nivel, ranking y canjes. | | `release-2026-07-14-portal-alumno.md` | Historial completo, commits, archivos y evidencia del release. | | `frontend-ui-standard.md` | Uso de NG-ZORRO y estándar compartido de componentes. | +| `piloto-visual-cursos-2026-07-19.md` | Contrato visual, iconografía y espacios de imágenes de Cursos. | +| `release-2026-07-19-ciclo-aprendizaje.md` | Reestructuración de Cursos, Misiones, Evaluaciones y Resultados. | +| `plan-evolucion-visual-portal-alumno.md` | Bloques completados y orden vigente de los módulos pendientes. | ## Portal de familias diff --git a/docs/ai-project-context.md b/docs/ai-project-context.md index 9416a902..fd34bd0a 100644 --- a/docs/ai-project-context.md +++ b/docs/ai-project-context.md @@ -4,7 +4,7 @@ This document is written for future AI agents and developers. It summarizes the current DAEMON system, the cloud decisions already made, the important files, and the traps that caused confusion during the migration. -Last updated: 2026-07-15. +Last updated: 2026-07-19. ## 1. What DAEMON is @@ -87,11 +87,19 @@ Student portal documentation: docs/sistema-visual-portal-alumno.md docs/portal-alumno.md docs/gamificacion-xp-daemons.md +docs/estados-vacios-daemon.md +docs/plan-evolucion-visual-portal-alumno.md docs/release-2026-07-14-portal-alumno.md ``` Current student visual direction: +- Continue the current Codex work from `codex/antigravity-integration`; the + branch name is historical and does not authorize importing Antigravity work. +- `feature/frontend-design-system` is explicitly excluded from future work. +- The Courses icon audit and the Missions/Evaluations learning-cycle + restructuring are complete in the active PR branch. The next visual block is + Tools IA: chatbot, bot configuration and conversation states. - Inter only. - Solid colors; no gradients in the main student modules. - Canvas `#f4f7fb`, white surfaces, light borders and restrained shadows. @@ -100,6 +108,23 @@ Current student visual direction: - Existing purple sidebar preserved. - Compact header that displays XP level and DAEMONS separately. - Dashboard, profile, missions, ranking and store share one visual language. +- Confirmed empty datasets use the shared NG-ZORRO-based `app-estado-vacio` + with the canonical lossless robot, contextual copy and real actions; loading + and errors remain distinct states. +- Courses (`/alumno/recursos`) is the first catalog/exploration pilot using a + dedicated illustrated hero, real status filters, accent-insensitive search, + course progress, a contextual aside and complete loading/empty/error states. +- Optional course illustrations use the shared `daemon-illustration-slot`, + which preserves aspect ratio and renders a safe fallback when an asset is + pending or fails. See `docs/piloto-visual-cursos-2026-07-19.md`. +- Courses now registers replaceable contracts for its hero, course covers and + progress companion; its confirmed empty state uses the canonical shared + robot. Icons distinguish all, not-started, in-progress and completed states. +- Missions list/detail/delivery and student Evaluations/Results now share a + complete remote-state discipline without becoming one generic page shell. + Pending or approved missions cannot be resubmitted from the UI, rejected + missions can be corrected, and assessments require every answer before + submission. See `docs/release-2026-07-19-ciclo-aprendizaje.md`. The earlier experimental Bento/glass implementation was corrected. Do not use that first iteration as the target for new student screens. @@ -397,8 +422,11 @@ Backend health: https://daemon-5vo1.onrender.com/api/v1/salud `npm run build` keeps the initial bundle below the configured 1 MB warning budget. Heavy NG-ZORRO table, upload and modal styles are loaded with the lazy -student or teacher layout instead of the public shell. Global Sass partials use -`@use`, so a clean build should not emit the former deprecation warning. +student or teacher layout instead of the public shell. The current clean build +still emits four Sass deprecation warnings because `src/styles.scss` imports +`layout`, `components`, `popovers` and `gamification` with `@import`. They are +pre-existing non-blockers, but the future Sass migration should replace them +with `@use`/`@forward` and verify global selector order. ## 15. Verification checklist diff --git a/docs/colaboracion-ias.md b/docs/colaboracion-ias.md new file mode 100644 index 00000000..2a6d50ec --- /dev/null +++ b/docs/colaboracion-ias.md @@ -0,0 +1,75 @@ +# Protocolo de colaboración Codex–Antigravity + +## Objetivo + +Permitir trabajo frontend paralelo sin mezclar cambios incompletos, romper +contratos funcionales ni desplegar directamente una propuesta visual. + +## Ramas y responsabilidades + +| Superficie | Responsable | Rama | +| --- | --- | --- | +| Implementación visual acotada | Antigravity | `feature/frontend-design-system` o `antigravity/` | +| Auditoría, corrección e integración | Codex | `codex/antigravity-integration` | +| Producción | pipeline validado | `main` | + +`main` no es una rama de trabajo. Antigravity entrega commits atómicos; Codex +los revisa y los cherry-pickea. Solo una integración con todos los gates en +verde puede proponerse para merge. + +## Propiedad temporal de archivos + +Cada handoff declara qué archivos posee el agente durante la tarea. El otro +agente no los edita hasta que exista un commit entregado o una liberación +explícita. Los archivos de shell (`layout-*`, `sidebar-portal`, topbars, +interceptores, rutas y estilos globales) requieren propiedad exclusiva porque +afectan muchos módulos. + +No se fuerza una sincronización copiando el working tree de la otra IA. Se +intercambian hashes de commit reproducibles. + +## Flujo de integración + +1. Ambos agentes parten del mismo `origin/main` documentado. +2. Antigravity implementa un solo módulo y ejecuta sus gates. +3. Antigravity actualiza `antigravity-to-codex.md` y entrega un hash. +4. Codex revisa contratos, diff, responsive, accesibilidad y rendimiento. +5. Codex cherry-pickea el commit en `codex/antigravity-integration`. +6. Las correcciones se registran en un commit separado y en + `codex-to-antigravity.md`. +7. Se abre un PR borrador. No se despliega una rama con archivos sin commit ni + con gates incompletos. + +## Gates mínimos + +```text +npm run check:architecture +npm run check:student-visual +npm run test:ci +npm run build +QA 1440×900, 1024×768, 390×844 y 360×800 +loading / datos / vacío / error / reintento +teclado / foco / reduced motion / objetivo táctil >= 40 px +``` + +Para cambios de autenticación, API o backend también se exige la suite Laravel +y una revisión específica de seguridad. Para producción se añaden CI, E2E y +smoke; nunca se asume que un build local autoriza el deploy. + +## Decisiones vigentes + +- La ficha redundante de usuario no se muestra dentro del sidebar; el enlace + `Mi perfil` se conserva. La cuenta vive en el topbar. +- El tour del alumno apunta al control `#topbar-perfil`. +- Se conservan el sidebar morado, sus rutas y los IDs de navegación del tour. +- Los `401` de rutas protegidas limpian la sesión; no se silencian para evitar + un logout. +- Los cambios visuales no pueden retirar funciones ni reemplazar skeletons por + una pantalla vacía con spinner cuando existe una estructura conocida. + +## Conflictos + +Si ambos agentes necesitan el mismo archivo, se detiene ese archivo, se entrega +primero el commit del propietario actual y el segundo agente rebasa su trabajo +sobre el commit aceptado. No se resuelven conflictos eligiendo automáticamente +“ours” o “theirs”. diff --git a/docs/estados-vacios-daemon.md b/docs/estados-vacios-daemon.md new file mode 100644 index 00000000..74704502 --- /dev/null +++ b/docs/estados-vacios-daemon.md @@ -0,0 +1,104 @@ +# Estados vacíos de DAEMON + +Estado vigente desde el 19 de julio de 2026. + +DAEMON usa un único contrato visual para representar una colección válida que +todavía no contiene elementos. La implementación canónica es el componente +standalone `app-estado-vacio`, construido sobre `nz-empty` de NG-ZORRO. + +## Qué representa + +Un estado vacío confirma que la consulta terminó correctamente y devolvió cero +resultados. No reemplaza: + +- la carga inicial o una actualización en curso; +- un error de red, permisos o servidor; +- un campo multimedia todavía sin completar; +- una instrucción, bloqueo o logro completado que tenga semántica propia. + +Carga, error y vacío deben seguir siendo estados mutuamente excluyentes. Si una +actualización falla pero existen datos anteriores, se conservan esos datos y se +muestra un aviso; no se sustituye el contenido por el robot. + +## Implementación canónica + +```html + + + +``` + +Contrato disponible: + +| Entrada | Uso | +| --- | --- | +| `titulo` | Explica el estado sin culpar al usuario. | +| `descripcion` | Indica por qué está vacío, qué ocurrirá o qué puede hacer. | +| `tamano` | `default`, `compact` o `mini`, según el espacio disponible. | +| `compacto` | Alias heredado de `tamano="compact"`. | +| `imagen` | Excepción documentada; por defecto usa el robot canónico. | +| `anunciar` | Controla `aria-live`; se desactiva en popovers ya anunciados. | + +Las acciones se proyectan en el footer de `nz-empty`. Debe existir una sola +acción primaria. Una acción secundaria solo se agrega cuando ofrece una ruta +real y distinta; nunca se inventa una acción para llenar espacio. + +## Tamaños + +- `default`: páginas y paneles principales. +- `compact`: tarjetas, pestañas, resultados filtrados e inventarios. +- `mini`: popovers y superficies de muy poca altura, como notificaciones. + +En móvil las acciones ocupan el ancho disponible y mantienen un área táctil +legible. La tipografía sigue siendo Inter, igual que el resto del portal; los +controles y la estructura accesible provienen de NG-ZORRO. + +## Ilustración canónica + +```text +frontend-angular/public/img/empty/empty-robot.webp +``` + +- Fuente entregada: PNG transparente de 1254 × 1254 px. +- Salida: WebP lossless transparente, 288 774 bytes. +- Reducción frente al PNG original: 52,8 %. +- Diferencia de píxeles decodificados: cero. +- SHA-256: `9813FCC986580ACDB395E70B0900A1A8DF8A83ED612857D3D59EC66FED6236CA`. + +No se generó un SVG automático. La ilustración tiene volumen 3D, iluminación, +sombras y transparencias; vectorizarla produciría una malla enorme o una +aproximación visual distinta. El WebP lossless conserva exactamente el robot y +reduce de forma material el peso de descarga. Si el recurso falla, el +componente muestra un fallback SVG liviano sin exponer texto alternativo +redundante: el título y la descripción ya comunican el estado. + +## Redacción + +1. El título describe qué ocurrirá: “Tu primera insignia está por llegar”. +2. La descripción explica el origen o siguiente paso con lenguaje breve. +3. El botón usa un verbo concreto: “Actualizar cursos”, “Ver misiones”. +4. No usar mensajes técnicos, bromas ambiguas ni expresiones que parezcan error. +5. Adaptar pronombres cuando se consulta el perfil de otra persona. + +## Decisión sobre configuración global + +NG-ZORRO permite configurar una imagen vacía global. DAEMON no lo hace porque +también afectaría vacíos internos de selects, tablas y otros controles compactos, +donde este robot sería desproporcionado. Los vacíos editoriales se declaran con +`app-estado-vacio`; los controles internos conservan el comportamiento estándar +de la librería. + +## Verificación mínima + +```powershell +cd frontend-angular +npm test -- --runInBand src/app/shared/componentes/estado-vacio/estado-vacio.spec.ts +npm run check:architecture +npm run build +``` + +La prueba compartida comprueba la imagen canónica, el contenido contextual, la +acción proyectada y el fallback ante un recurso roto. diff --git a/docs/firebase-auth.md b/docs/firebase-auth.md index 41135414..cca0fb35 100644 --- a/docs/firebase-auth.md +++ b/docs/firebase-auth.md @@ -185,6 +185,24 @@ firebase.projectId: 'daemon-a41f8' La config web de Firebase no es secreto. Las service accounts si son secreto. +### Desarrollo local y cookies + +Angular y Laravel deben usar el mismo hostname durante las pruebas locales: + +```text +Frontend: http://localhost:4200 o http://localhost:4300 +API: http://localhost:8000/api/v1 +``` + +No mezclar `localhost` con `127.0.0.1`. Aunque ambos apunten al mismo equipo, +el navegador los considera sitios distintos. La llamada de login puede +responder HTTP 200, pero la cookie HttpOnly `daemon_access` con `SameSite=Lax` +no queda disponible para la siguiente consulta autenticada y el interceptor +termina limpiando la sesión. + +Si se elige `127.0.0.1`, tanto el frontend como el backend deben servirse con +ese mismo hostname. + ## Variables backend Laravel necesita: diff --git a/docs/frontend-architecture.md b/docs/frontend-architecture.md index ba6089e1..7e6dfd22 100644 --- a/docs/frontend-architecture.md +++ b/docs/frontend-architecture.md @@ -57,6 +57,11 @@ sesión, la autenticación o la configuración del shell. Los componentes puramente presentacionales, como la moneda DAEMON y los estados vacíos, permanecen en `shared`. +`shared/componentes/estado-vacio` es la única abstracción editorial para +colecciones vacías. Encapsula `nz-empty`, no conoce servicios ni rutas y recibe +sus acciones por proyección de contenido. Su contrato visual se documenta en +`docs/estados-vacios-daemon.md`. + ## Modelos, configuración, directivas y pipes - Un modelo utilizado solo por una feature se coloca junto a esa feature. diff --git a/docs/handoffs/antigravity-to-codex.md b/docs/handoffs/antigravity-to-codex.md new file mode 100644 index 00000000..c3db5356 --- /dev/null +++ b/docs/handoffs/antigravity-to-codex.md @@ -0,0 +1,19 @@ +# Handoff de Antigravity a Codex + +Antigravity es el único autor de este archivo durante una entrega. Completar +sin borrar el historial anterior. + +## Entrega + +- Fecha: +- Rama: +- Commit base: +- Commit entregado: +- Módulo y objetivo: +- Archivos modificados: +- Funciones preservadas: +- Estados comprobados: loading / datos / vacío / error / reintento. +- Resultados de arquitectura, contrato visual, tests y build: +- QA desktop/tablet/móvil: +- Riesgos o decisiones pendientes: +- Archivos reservados temporalmente: diff --git a/docs/handoffs/codex-to-antigravity.md b/docs/handoffs/codex-to-antigravity.md new file mode 100644 index 00000000..33f5d470 --- /dev/null +++ b/docs/handoffs/codex-to-antigravity.md @@ -0,0 +1,55 @@ +# Handoff de Codex a Antigravity + +Actualizado: 19 de julio de 2026. + +## Base compartida + +- Producción funcional: `2d76c4558149a48b6b4f91013dc00d6e1afe2ce4`. +- `origin/main` al iniciar la integración: `55cbd75fd64a2cc529bfacbdfafb97357511f656`. +- Rama de revisión Codex: `codex/antigravity-integration`. + +## Revisión de entregas existentes + +Codex incorporó para revisión los commits originales: + +- `ace627c`: desafíos; +- `6928bcf`: recursos/cursos; +- `4c22d2a`: perfil. + +Se conservaron sus objetivos visuales, pero se corrigieron radios fuera del +sistema, movimientos decorativos, controles menores de 40 px, pérdida de +semántica, falta de skeleton, privacidad de correo, imágenes rotas y la +presentación incompleta de la recompensa dual XP/DAEMONS. La QA real también +detectó y corrigió los iconos de perfil que no estaban registrados. + +La eliminación de la ficha redundante de usuario del sidebar está aprobada. No +debe revertirse; el enlace de navegación `Mi perfil` permanece. Codex integró +la decisión migrando el paso del tour a `#topbar-perfil`. + +## Cambio no aceptado + +No entregar el cambio local de `token-interceptor.ts` que conserva una sesión +ante un `401` protegido. Un `401` significa que la autenticación dejó de ser +válida; la autorización insuficiente debe expresarse como `403`. Ese archivo +queda fuera del alcance visual. + +## Lote en cuarentena + +Los commits `047f6ce` a `4f7f29c` no están autorizados para integración. El +lote cambia 78 archivos y mezcla tienda, mascota, evaluaciones, competencia, +cuentos, ranking, comunidad, laboratorio, certificados, herramientas, misiones +y chatbot sin handoff por módulo. + +La auditoría de adiciones encontró, como mínimo, 26 degradados Tailwind, 16 +degradados radiales, 12 usos de `backdrop-blur`, 10 desplazamientos verticales +en hover y 106 radios arbitrarios de 20 px o más. Esto contradice el sistema +visual vigente y no puede corregirse con una aprobación global. Cada módulo +debe rehacerse y entregarse en un commit independiente con los gates y estados +documentados. + +## Próxima tarea sugerida para Antigravity + +Rehacer un único módulo —ranking o tienda, no ambos— desde la base común actual. +Preservar todas sus acciones y preparar los cinco estados de UI. No partir del +lote en cuarentena ni tocar panel, perfil, recursos, desafíos, sidebar, topbar, +interceptor, rutas o estilos globales hasta recibir un nuevo handoff. diff --git a/docs/infraestructura-operativa.md b/docs/infraestructura-operativa.md index 69163498..36e6ec2a 100644 --- a/docs/infraestructura-operativa.md +++ b/docs/infraestructura-operativa.md @@ -2,8 +2,14 @@ Estado tecnico al 15 de julio de 2026. -Actualización del 19 de julio: el monitor gratuito se ejecuta cada diez -minutos. Es una mitigación temporal sin SLA; ver +Actualización del 19 de julio: producción usa el plan gratuito declarado en +`render.yaml`. Render puede suspender un servicio gratuito después de quince +minutos consecutivos sin tráfico entrante. El monitor de GitHub está programado +cada diez minutos y el frontend conserva un keep-alive cada diez minutos sólo +durante sesiones autenticadas y visibles. Ambos son mitigaciones sin SLA: los +workflows programados pueden retrasarse y una sesión cerrada no genera tráfico. +La única garantía operativa de no suspensión es migrar a una instancia de pago; +ver `implementacion-plataforma-estandar-2026-07-19.md`. La recuperación y las barreras destructivas están en `incidentes/2026-07-18-restauracion-supabase.md`. @@ -38,6 +44,11 @@ El entrypoint invalida caches cuando cambian variables, ejecuta `php artisan mig No existe una ruta HTTP para ejecutar migraciones. +El health check de Render apunta a `/api/v1/salud`. El workflow +`production-monitor.yml` y el servicio Angular `KeepAlive` consultan salud de +forma independiente, pero no deben documentarse ni operarse como sustitutos de +una instancia siempre activa. + ## Backups `.github/workflows/supabase-backup.yml` ejecuta diariamente: diff --git a/docs/piloto-visual-cursos-2026-07-19.md b/docs/piloto-visual-cursos-2026-07-19.md new file mode 100644 index 00000000..89e88722 --- /dev/null +++ b/docs/piloto-visual-cursos-2026-07-19.md @@ -0,0 +1,483 @@ +# Piloto visual y funcional: Cursos del estudiante + +Fecha: 2026-07-19 + +Ruta: `/alumno/recursos` + +Arquetipo: A — catálogo, exploración y continuidad + +Estado: implementado y validado en la rama de integración; pendiente de revisión y merge del PR. + +## 1. Objetivo + +Actualizar el módulo **Mis cursos** con la estructura visual aprobada para el +portal del estudiante, sin copiar el dashboard ni convertir cada pantalla en +la misma plantilla. El módulo debe: + +- mostrar únicamente cursos, unidades, lecciones y progreso reales; +- conservar las reglas de negocio y el endpoint existente; +- ser usable en escritorio y en un teléfono de 390 x 844 px; +- diferenciar carga, actualización, error, sin asignaciones y sin resultados; +- reservar espacios semánticos para ilustraciones futuras sin romper la + interfaz cuando el archivo todavía no existe; +- mantener intactos el dashboard aprobado, el sidebar morado y los IDs del tour. + +## 2. Auditoría previa + +### Datos y comportamiento existentes + +- Endpoint de lectura: `GET /api/v1/alumno/aprendizaje`. +- Endpoint de avance: `PUT /api/v1/alumno/aprendizaje/lecciones/{id}/progreso`. +- Telemetría permitida al completar: `lesson_completed`, con `lesson_id` y + `module: cursos`. +- La API entrega cursos, unidades, lecciones, nivel y progreso. No entrega + favoritos, períodos seleccionables, catálogo público ni XP obtenido por + curso. +- La capa `Api` ya implementa caché de GET, deduplicación de solicitudes y + stale-while-revalidate. El módulo no crea otra caché paralela. + +### Stack y límites + +| Elemento auditado | Estado comprobado | Decisión del piloto | +| --- | --- | --- | +| Angular | `21.2.18`, standalone, signals y lazy routes | conservar `OnPush`, signals y la ruta lazy existente | +| Angular CDK | `21.0.0` | no requerido: no hay overlay, virtualización ni cambio de DOM por breakpoint | +| NG-ZORRO | `21.3.2` | usar `NzButtonModule`; no recrear el comportamiento de botones | +| Iconografía | Font Awesome ya registrado en el portal | reutilizar sólo los iconos usados y no añadir otra familia | +| Tailwind | `3.4.19` disponible | el piloto usa SCSS encapsulado para estados y composición específicos | +| Tokens | variables `--daemon-*` en `styles.scss` | mapear color, superficie, borde y texto; no cambiar estilos globales | +| Caché | `Api` con GET cacheado, deduplicación y SWR | no crear una segunda caché en la feature | +| Empty state compartido | `app-estado-vacio`, útil para mensajes simples | no duplicarlo; `IllustrationSlot` resuelve únicamente el contrato visual opcional | +| Assets | PNG/Rive existentes en `public`; no hay portadas de curso | reutilizar dos PNG aprobados y reservar portadas futuras con fallback | +| Duplicación detectada | varias pantallas poseen vacíos simples, pero ninguna reserva un asset reemplazable | extraer sólo `daemon-illustration-slot`, no un page shell anticipado | + +El nuevo slot vive en `shared` y no importa `core` ni `features`. No se añadió +ninguna dependencia. Se usan los tokens globales de DAEMON: canvas claro, +superficies blancas, bordes suaves, azul para acción/progreso y ámbar sólo para +DAEMONS. + +### Decisiones para evitar funciones ficticias + +- Filtros implementados: Todos, Por iniciar, En progreso y Completados. +- Búsqueda implementada sobre títulos y descripciones de curso, unidad y + lección; ignora mayúsculas y tildes. +- No se agregaron filtros de favoritos o período porque el contrato real no los + soporta. +- No se muestra XP por curso porque el endpoint no lo devuelve. +- Los enlaces complementarios llevan a rutas existentes: Misiones y + Herramientas IA. + +## 3. Referencias de producto y sistemas de diseño + +La implementación toma principios, no copias literales: + +| Referencia | Patrón estudiado | Problema que resuelve | Adaptación para DAEMON | Riesgo de aplicarlo mal | +| --- | --- | --- | --- | --- | +| Moodle Course overview / Courses block | cursos inscritos, búsqueda y progreso | localizar y retomar aprendizaje | filtros por estado real y avance visible | copiar controles que la API de DAEMON no soporta | +| Canvas LMS guides | jerarquía curso-contenido-estado | navegar estructuras académicas profundas | curso, unidades y lecciones con disclosure progresivo | convertir la pantalla infantil en una administración densa | +| Khan Academy mastery | progreso por unidad/curso | explicar dominio y continuidad | resumen global y porcentaje por curso | presentar el porcentaje como nota académica | +| Duolingo course path | siguiente acción y continuidad | evitar que el estudiante se pierda | CTAs reales hacia lecciones, Misiones y Herramientas | gamificar sin relación con aprendizaje real | +| Atlassian Empty State | explicación y próximo paso | orientar cuando todavía no hay datos | empty de asignación con actualizar y abrir Misiones | usar la misma explicación para error y vacío | +| Carbon Empty States | taxonomía de vacíos y errores | distinguir causa y recuperación | separar sin asignaciones, sin coincidencias, offline y error | convertir cada ausencia en una ilustración innecesaria | +| Fluent 2 Layout | reflow de una sola interfaz | conservar contenido al reducir ancho | aside debajo, filtros táctiles y tarjetas apiladas | duplicar DOM móvil y desktop | +| NG-ZORRO | controles y estados accesibles | consistencia de interacción Angular | botones fiables con identidad visual DAEMON | dejar apariencia administrativa predeterminada | + +Referencias: + +- https://docs.moodle.org/501/en/Courses_block +- https://docs.moodle.org/39/en/Course_overview_block +- https://community.canvaslms.com/t5/Canvas-Basics-Guide/tkb-p/basics +- https://support.khanacademy.org/hc/en-us/articles/360035457352 +- https://blog.duolingo.com/duolingo-101-how-to-learn-a-language-on-duolingo/ +- https://atlassian.design/components/empty-state +- https://carbondesignsystem.com/patterns/empty-states-pattern/ +- https://fluent2.microsoft.design/layout +- https://ng.ant.design/components/empty/en +- https://ng.ant.design/components/input/en + +## 4. Arquitectura de la pantalla + +```text +Mis cursos +├── Encabezado ilustrado +│ ├── contexto, título y descripción +│ ├── cantidad real de cursos asignados +│ └── slot de ilustración de guía +├── Alerta no bloqueante cuando falla una actualización +├── Barra de herramientas +│ ├── filtros reales con conteos +│ ├── búsqueda local normalizada +│ └── estado de actualización +├── Contenido principal +│ ├── tarjetas de curso +│ │ ├── slot de portada +│ │ ├── nivel, estado, descripción y metadatos +│ │ ├── progreso accesible +│ │ └── unidades, lecciones y acciones existentes +│ ├── estado sin resultados +│ ├── estado sin asignaciones ilustrado +│ └── acceso a herramientas disponibles +└── Resumen complementario + ├── progreso global real + ├── explicación del flujo + └── recomendación de Misiones +``` + +No existe un `
` interno: el layout del portal ya aporta el landmark +principal. Esto evita landmarks anidados y conserva una estructura accesible. + +## 5. Componente compartido de ilustración + +Componente: `daemon-illustration-slot` + +Ubicación: `frontend-angular/src/app/shared/componentes/illustration-slot/` + +Entradas: + +| Entrada | Uso | +| --- | --- | +| `src` | URL opcional del asset actual | +| `alt` | texto alternativo; vacío cuando la imagen es decorativa | +| `kind` | `decorative`, `contextual`, `instructional`, `reward`, `empty-state`, `hero` o `interactive` | +| `aspectRatio` | reserva proporción y evita saltos de layout | +| `assetName` | contrato de nombre para reemplazo futuro | +| `eager` | prioriza sólo ilustraciones por encima del pliegue | + +Comportamiento: + +- si el asset existe, se renderiza dentro de la proporción declarada; +- si falta o falla, aparece un fallback neutro y deliberado; +- el fallback informativo hereda nombre accesible desde `alt`; +- el fallback decorativo no añade ruido al lector de pantalla; +- el hero usa `loading=eager` y `fetchpriority=high`; +- tarjetas y estados secundarios usan carga diferida; +- el espacio no colapsa, por lo que un ilustrador puede sustituir el asset sin + rediseñar la página. + +El valor semántico `none` se expresa omitiendo el componente; así no se crea un +wrapper vacío cuando una región no necesita ilustración. + +## 6. Contrato de assets pendientes + +Los assets actuales son provisionales y ya están en Firebase Hosting. Los +nombres de destino quedan registrados en el DOM mediante `data-asset-name`. + +### Guía del catálogo + +- Nombre objetivo: `course-catalog-guide.webp`. +- Uso: hero contextual/decorativo. +- Asset provisional: `/img/hero-monster.png`. +- Formato recomendado: WebP con transparencia. +- Lienzo recomendado: 840 x 470 px; zona segura de 10–12%. +- Composición: personaje concentrado a la derecha, sin texto incrustado. +- Alt: vacío porque el encabezado ya comunica el contenido. +- Mobile: debe seguir siendo legible al mostrarse cerca de 220 x 124 px. + +### Estado sin cursos + +- Asset canónico: `/img/empty/empty-robot.webp`. +- Uso: estado vacío compartido mediante `app-estado-vacio`. +- No es un contrato pendiente específico de Cursos: se conserva el mismo robot + lossless estandarizado para vacíos confirmados en toda la plataforma. +- El título, la explicación y las acciones contextuales viven en HTML; la + ilustración no contiene texto ni botones. +- Mobile: permanece legible cerca de 240 x 180 px. + +### Portada por curso + +- Nombre objetivo: `course-{id}-cover.webp`. +- Campo futuro ya admitido por la vista: `ilustracion_url`. +- Proporción: 16:7. +- Tamaño recomendado: 960 x 420 px. +- Zona segura: 10%; foco visual centrado o ligeramente a la derecha. +- No incluir título, nivel, porcentaje ni botones dentro de la imagen. +- Si el backend todavía no entrega una URL, se mantiene el fallback y el + espacio reservado. + +### Acompañante del progreso + +- Nombre objetivo: `course-progress-companion.webp`. +- Uso: recompensa visual dentro del resumen de progreso general. +- Asset provisional: `/img/robot-mision.png`. +- Proporción reservada: 16:5. +- Formato recomendado: WebP con transparencia, sin texto incrustado. +- Composición: personaje o criatura a la derecha, con la zona izquierda libre + para que el resumen siga siendo legible. +- Alt: vacío; las métricas y la explicación ya existen como texto HTML. + +## 6.1. Auditoría final de iconografía + +La revisión pendiente quedó resuelta en el lote del ciclo de aprendizaje: + +- Todos: cuadrícula, porque representa el conjunto completo; +- Por iniciar: bandera, porque comunica un punto de partida; +- En progreso: reproducción, porque comunica continuidad; +- Completados: círculo validado, porque comunica cierre confirmado; +- ayuda: información; +- asignación docente: grupo de personas; +- ruta publicada: libro abierto; +- siguiente acción: cohete; +- fallo remoto: triángulo de advertencia. + +No se reutiliza el mismo pictograma para estados académicos distintos. Los +iconos siguen acompañados por texto y no son el único portador de significado. + +## 7. Estados completos + +### Carga inicial + +- Skeleton con barra de herramientas, tarjetas y resumen lateral. +- Replica la geometría final para reducir cambios de layout. +- Respeta `prefers-reduced-motion`. + +### Contenido disponible + +- Muestra tarjetas derivadas del contrato real. +- Calcula estado y porcentaje fuera del template mediante signals computadas. +- Cada progreso incluye `role=progressbar`, valor y nombre accesible. + +### Actualización + +- Conserva los últimos datos mientras solicita una versión fresca. +- Muestra “Actualizando” sin reemplazar todo el módulo por un spinner. +- Si falla, el contenido permanece visible y aparece una alerta con Reintentar. + +### Sin asignaciones + +- Ilustración, explicación y dos acciones reales: actualizar y abrir Misiones. +- No confunde “todavía no hay cursos” con un fallo de red. + +### Sin resultados por filtros + +- Mensaje independiente: “No encontramos coincidencias”. +- Acción para limpiar filtro y búsqueda. +- El resumen general permanece visible porque la matrícula real no desapareció. + +### Error de carga inicial + +- Mensaje bloqueante claro y botón para intentar nuevamente. +- No se muestra simultáneamente como si fuera un estado vacío académico. + +### Sin conexión, timeout y acceso denegado + +- `ApiError('offline')` presenta “Sin conexión con DAEMON” y una recuperación + real; nunca se confunde con ausencia de asignaciones. +- `ApiError('timeout')` explica que la conexión está tardando. +- HTTP `401/403` presenta “Acceso no disponible” y una salida segura al inicio, + en vez de un reintento infinito. + +### Datos conservados o parciales + +- Si falla una actualización y ya existían cursos, se conservan las tarjetas, + aparece la explicación correspondiente y se etiqueta “Datos guardados”. +- Un curso sin unidades o una unidad sin lecciones conserva su estructura y + muestra el mensaje contextual en esa región, sin ocultar el resto del + catálogo. + +### Envío y confirmación + +- Mientras se guarda una lección, el control muestra “Guardando…” y bloquea + envíos duplicados. +- Al completarse, un `role=status` confirma la actualización del progreso. +- Si falla el guardado, se muestra un error de acción independiente; no ofrece + un botón “Reintentar” que sólo recargaría el catálogo. + +## 8. Responsividad + +### Escritorio + +- Encabezado en dos regiones con ilustración a la derecha. +- Contenido principal en tarjetas y resumen complementario sticky. +- Funciona con sidebar expandido y colapsado. +- El contenido no produce scroll horizontal global. + +### Tableta + +- El resumen deja de ser sticky y se convierte en una grilla de apoyo. +- Buscador y filtros se redistribuyen sin reducir sus áreas táctiles. + +### Móvil 360–390 px + +- Hero en una sola columna; la ilustración se conserva debajo del texto. +- Buscador ocupa todo el ancho. +- Filtros mantienen un carrusel táctil horizontal sin barra visual nativa. +- Tarjetas, unidades, lecciones y acciones se apilan. +- Controles de acción mantienen 40–44 px como mínimo. +- Tipografía de inputs permanece en 16 px para evitar zoom automático en iOS. +- El ancho real de la página permanece dentro del viewport; sólo la lista de + filtros tiene desplazamiento horizontal intencional. +- La navegación inferior del portal sigue siendo dueña de la zona fija. + +La adaptación también responde al ancho real del contenedor. Esto evita que +el catálogo se comprima cuando el sidebar permanece expandido en una tableta, +aunque el viewport por sí solo todavía sea ancho. + +## 9. Accesibilidad + +- Un único `h1` y jerarquía `h2` para cursos y paneles. +- Landmarks con nombres: catálogo, filtros, resultados y resumen. +- Filtros exponen `aria-pressed` y conteos visibles. +- Búsqueda tiene etiqueta accesible aunque visualmente use placeholder. +- Estados de error usan `role=alert`; actualizaciones usan `role=status`. +- Iconos decorativos se ocultan a tecnologías asistivas. +- Botones de lección describen la acción y el nombre de la lección. +- Focus visible de alto contraste. +- Movimiento reducido soportado. + +## 10. Rendimiento y datos + +- `ChangeDetectionStrategy.OnPush`. +- Signals y `computed` para filtros, estados y porcentajes. +- No se ejecutan getters de progreso repetitivos desde el template. +- La caché, deduplicación y SWR permanecen centralizados en `Api`. +- Al cambiar de módulo y volver, la primera representación usa inmediatamente + la respuesta cacheada mientras se revalida según la política existente; el + skeleton queda reservado para una carga inicial sin datos disponibles. +- Hero prioritario; resto de imágenes diferidas. +- No se incorporaron nuevas dependencias ni bundles globales. + +## 11. Pruebas automatizadas + +Archivos: + +- `recursos.spec.ts`: catálogo, filtros, búsqueda, estado sin asignaciones, + progreso local y telemetría. +- `illustration-slot.spec.ts`: fallback, contrato de asset y prioridad del hero. + +Validaciones requeridas: + +```powershell +cd frontend-angular +npm test -- --runInBand +npm run check:architecture +npm run build +``` + +Resultado del piloto antes de publicar: + +- pruebas específicas: 2 suites y 10 casos aprobados; +- suite frontend completa: 13 suites y 49 casos aprobados; +- suite backend completa: 134 casos y 475 aserciones aprobados; +- arquitectura `shared/core/features`: aprobada; +- build de producción: aprobado; +- bundle inicial: 908.07 kB, bajo el presupuesto de advertencia de 1 MB; +- chunk lazy de Cursos: 46.90 kB sin comprimir, 9.95 kB estimado transferido; +- no se agregaron advertencias nuevas; permanecen cuatro advertencias Sass + preexistentes por `@import` en `src/styles.scss`. +- Jest conserva una advertencia preexistente de `ts-jest` sobre + `esModuleInterop`; no produjo fallos ni fue causada por este módulo. + +La suite completa debe volver a ejecutarse después de cualquier ajuste final y +antes del push. + +## 12. QA visual ejecutado + +Se levantó una API efímera y aislada únicamente para representar cursos con +estados Por iniciar, En progreso y Completado. El fixture y su configuración se +eliminaron al terminar; no se escribió en Supabase ni se incorporó al commit. + +Comprobaciones: + +- 1440 x 900 con sidebar colapsado: aprobado; +- 1440 x 900 con sidebar expandido: aprobado; +- 1024 x 768 con sidebar expandido: aprobado; el catálogo responde al ancho + disponible y el topbar oculta sólo texto secundario dentro de su propio + componente; +- 390 x 844 con header y bottom navigation reales: aprobado; +- 360 x 800 con header y bottom navigation reales: aprobado; +- sin overflow horizontal global: aprobado; +- carrusel táctil de filtros: aprobado y sin scrollbar visible; +- estado sin resultados: aprobado; +- estado sin asignaciones e ilustración reservada: aprobado; +- error de conexión con datos previos conservados: aprobado; +- acceso denegado con salida segura: aprobado; +- confirmación de lección guardada mediante región live: aprobado; +- DOM semántico y nombres accesibles: aprobado; +- consola de Cursos después de cargar el fixture: sin errores del módulo. + +El fixture incompleto produjo errores del dashboard antes de navegar a Cursos; +no corresponden al módulo ni al código publicado. Además, el dashboard +aprobado conserva un overflow horizontal propio a 1024 px provocado por su +hero y sus tarjetas. No se corrigió dentro de este piloto porque el objetivo +protege expresamente esa pantalla; el catálogo de Cursos sí permanece dentro +del viewport en el mismo tamaño. + +## 13. Hallazgo del entorno local + +El frontend del PR se sirve en `http://localhost:4300`; el puerto `4200` +pertenece al worktree paralelo de Antigravity y no contiene necesariamente este +piloto. El backend local responde en `http://localhost:8000/api/v1`. +Frontend y API deben conservar ese mismo hostname para que la cookie HttpOnly +no se vuelva cross-site. + +La cuenta documentada `jose123` fue comprobada de extremo a extremo: login +HTTP 200, cookie aceptada y navegación real a `/alumno`, sin modificar cuenta, +contraseña ni datos de producción. La regresión “Ingresando…” se reprodujo con +la API en `127.0.0.1` y quedó resuelta al restaurar `localhost`, que es el mismo +hostname del frontend de desarrollo. + +## 14. Archivos de implementación + +```text +frontend-angular/src/app/features/alumno/pages/recursos/recursos.ts +frontend-angular/src/app/features/alumno/pages/recursos/recursos.html +frontend-angular/src/app/features/alumno/pages/recursos/recursos.scss +frontend-angular/src/app/features/alumno/pages/recursos/recursos.spec.ts +frontend-angular/src/app/core/layouts/topbar-alumno/topbar-alumno.scss +frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.ts +frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.html +frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.scss +frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.spec.ts +``` + +## 15. Fuera de alcance deliberado + +- No se modificó el dashboard del estudiante. +- No se modificó el sidebar ni sus IDs del tour. +- No se crearon ilustraciones definitivas. +- No se alteró el contrato de la API ni la base de datos. +- No se inventaron favoritos, períodos, XP por curso ni rutas externas. +- No se desplegó ni se mezcló la rama automáticamente. + +## 16. Rollback + +El cambio se revierte eliminando el componente compartido de ilustración y +restaurando los cuatro archivos del módulo Cursos. No requiere migraciones, +cambios de variables de entorno ni reversión de datos. + +## 17. Matriz de cierre del objetivo + +| Requisito aplicable al piloto Cursos | Evidencia autoritativa | Estado | +| --- | --- | --- | +| Dashboard principal intacto | el commit no contiene archivos de `panel-alumno`, layout, sidebar ni estilos globales | Cumplido | +| Captura usada como referencia, no como plantilla universal | sólo se migró el arquetipo A `/alumno/recursos`; no se alteraron otros módulos | Cumplido | +| Estructura de catálogo con contenido y aside | `recursos.html` separa hero, toolbar, main content, discovery y aside | Cumplido | +| Datos y acciones reales | endpoints `/alumno/aprendizaje` y progreso existentes; sin favoritos, períodos ni XP ficticio | Cumplido | +| Espacios permanentes y sustituibles para ilustraciones | `daemon-illustration-slot`, `aspectRatio`, `assetName`, fallback y carga priorizada/diferida | Cumplido | +| Ausencia de asset sin imagen rota | prueba de error de imagen y fallback SVG | Cumplido | +| Estados remotos completos aplicables | loading, success, empty, filtered-empty, error, offline, permission, refreshing, stale, partial, submitting y success-feedback | Cumplido | +| Mobile-first y sin pérdida funcional | QA 390 x 844; filtros táctiles, aside reubicado, lecciones y acciones disponibles | Cumplido | +| Accesibilidad más allá del color | landmarks, jerarquía, labels, estados live, focus, progressbars y reduced motion | Cumplido | +| Sin dependencias ni reglas de negocio nuevas | `package.json` y backend sin cambios; cálculos limitados a presentación de progreso ya entregado | Cumplido | +| Arquitectura y calidad | check de límites, contrato visual, Jest, build, Laravel y CI del PR | Cumplido tras repetir los gates finales | + +## 18. Refinamiento de jerarquía visual + +Después de revisar el piloto en contexto real se simplificó la cabecera y la +navegación del catálogo para acercarlas a la composición aprobada: + +- el hero dejó de ser una tarjeta independiente con borde y ahora se integra + directamente en el lienzo del módulo; +- el icono principal y los iconos informativos usan color sólido y contraste + directo, sin fondos pálidos ni contenedores decorativos; +- los filtros inactivos se presentan como icono y texto abiertos; únicamente el + filtro activo conserva una cápsula violeta porque representa selección; +- se retiraron los contadores encapsulados de los filtros, ya que el resumen + lateral mantiene las cantidades reales; +- la barra de búsqueda y filtros ya no se encierra en otra tarjeta y cambia a + dos filas antes de que una etiqueta pueda recortarse; +- se conservaron tarjetas, bordes y fondos cuando expresan estructura real: + curso, unidad, resumen, ayuda, estado o acción. + +Este refinamiento es exclusivamente visual. No modifica endpoints, filtros, +búsqueda, datos, progreso, caché ni contratos de ilustración. diff --git a/docs/plan-evolucion-visual-portal-alumno.md b/docs/plan-evolucion-visual-portal-alumno.md index 6ce4fb50..ec5f0995 100644 --- a/docs/plan-evolucion-visual-portal-alumno.md +++ b/docs/plan-evolucion-visual-portal-alumno.md @@ -1,6 +1,19 @@ # Plan de evolución visual del portal alumno -Estado inicial: 15 de julio de 2026. +Estado inicial: 15 de julio de 2026. Continuidad confirmada: 19 de julio de +2026. + +## Base confirmada para continuar + +El estado vigente es el trabajo de Codex conservado en +`codex/antigravity-integration`. El nombre de la rama es histórico: no significa +que se deban incorporar cambios de Antigravity. La rama +`feature/frontend-design-system` queda descartada como fuente de integración y +no se copiará ni fusionará en bloque. + +El dashboard conserva `/img/robot-mision.png` en el estado “Ruta completada por +ahora”. El login local `jose123` sigue siendo parte de la verificación funcional +y no debe alterarse durante cambios puramente visuales. ## Diagnóstico @@ -46,6 +59,39 @@ pero con más claridad, accesibilidad y solidez técnica que la vista heredada. ## Hoja de ruta +### Bloque completado: ciclo de aprendizaje + +El 19 de julio de 2026 quedó implementado el primer bloque posterior al piloto: + +1. Cursos: iconografía semántica, tres contratos reemplazables —hero, portada y + acompañante de progreso—, robot canónico para vacío y conservación de datos + reales. +2. Misiones: listado, detalle y entrega con continuidad, filtros de estado, + carga, vacío, error, reintento y bloqueo de reenvíos pendientes o aprobados. +3. Evaluaciones: preguntas accesibles, progreso de respuestas, prevención de + envíos incompletos y separación del error de carga y el error de envío. +4. Resultados: resumen calculado desde respuestas reales, historial responsive + y umbral de aprobación alineado con la regla vigente de 70 puntos. + +La evidencia técnica y los contratos se documentan en +`release-2026-07-19-ciclo-aprendizaje.md`. + +### Próximo bloque de trabajo + +1. Herramientas IA: chatbot, configuración del bot y estados de conversación. +2. Perfil, ranking y comunidad. +3. Tienda, canjes y mascota. +4. Proyectos y motores heredados. + +Para cada bloque se mantiene el mismo orden operativo: auditar contrato y +acciones reales; diseñar carga, vacío, error y recuperación; implementar +responsive y accesibilidad; validar localmente; publicar en la rama/PR; y +desplegar producción únicamente con autorización y gates completos. + +No mezclar varios módulos en un lote visual. Cada entrega debe conservar el +sidebar morado, Inter, XP separado de DAEMONS, los datos reales del backend y +las funciones existentes. + ### Fase 1. Núcleo de identidad - convertir el dashboard en un centro de operaciones y no en un resumen de KPIs; @@ -55,9 +101,15 @@ pero con más claridad, accesibilidad y solidez técnica que la vista heredada. ### Fase 2. Ciclo de aprendizaje -- reconstruir listado, detalle y entrega de misiones como una ruta continua; -- mejorar evaluaciones, resultados y recursos con contexto y siguiente acción; -- hacer visibles prerequisitos, recompensas y estados reales del backend. +- completado: reconstruir listado, detalle y entrega de misiones como una ruta + continua; +- completado: mejorar evaluaciones, resultados y recursos con contexto y + siguiente acción; +- completado: hacer visibles recompensas y estados reales disponibles en el + backend, sin inventar prerrequisitos que el contrato todavía no expone. + +Los iconos pendientes de Cursos quedaron resueltos con Font Awesome como única +familia, significado consistente, texto accesible y registro explícito. ### Fase 3. Progreso y recompensa diff --git a/docs/release-2026-07-19-ciclo-aprendizaje.md b/docs/release-2026-07-19-ciclo-aprendizaje.md new file mode 100644 index 00000000..c6c96b5e --- /dev/null +++ b/docs/release-2026-07-19-ciclo-aprendizaje.md @@ -0,0 +1,109 @@ +# Cierre del ciclo de aprendizaje del portal estudiante + +Fecha: 2026-07-19 + +Estado: implementado y validado localmente en la rama activa del PR. No se +desplegó en producción desde este lote. + +## Alcance + +Este bloque completa la reestructuración de: + +- Cursos: `/alumno/recursos`; +- Misiones: listado, detalle y entrega; +- Evaluaciones activas: `/alumno/evaluaciones`; +- Historial de resultados: `/alumno/resultados`. + +Se conservaron el login, el dashboard aprobado, el sidebar morado, las rutas, +los endpoints y las reglas XP/DAEMONS. No se incorporó código de la rama +descartada de Antigravity. + +## Cursos + +- Los filtros Todos, Por iniciar, En progreso y Completados usan pictogramas + diferentes y texto visible. +- La ayuda usa iconos propios para asignación docente, ruta publicada y + siguiente acción. +- Se registraron tres contratos de imagen reemplazables: + `course-catalog-guide.webp`, `course-{id}-cover.webp` y + `course-progress-companion.webp`. El vacío conserva el robot canónico + `/img/empty/empty-robot.webp` mediante `app-estado-vacio`. +- Los assets actuales son provisionales; el componente compartido mantiene la + proporción y evita imágenes rotas cuando llegue el arte definitivo. +- No se añadieron favoritos, periodos, XP por curso ni catálogos ficticios. + +## Misiones + +### Listado + +- Hero propio con el slot `missions-route-guide.webp`. +- Filtros: Todas, Disponibles, En revisión, Completadas y Por corregir. +- Cada tarjeta conserva recompensa, nivel, evidencia y acción real. +- Carga inicial, vacío, fallo remoto y reintento son estados independientes. + +### Detalle y entrega + +- Contratos TypeScript para misión, entrega y estado de entrega. +- La línea de estado distingue disponible, pendiente, aprobado y rechazado. +- Una entrega pendiente o aprobada no puede reenviarse desde la interfaz; esta + protección coincide con la validación 422 existente en Laravel. +- Una entrega rechazada conserva la corrección como acción válida. +- Un fallo al enviar no borra la evidencia escrita por el estudiante. + +## Evaluaciones y resultados + +### Evaluación activa + +- Preguntas agrupadas en `fieldset` con opciones de radio accesibles. +- Progreso visible y accesible de respuestas completadas. +- El envío se habilita únicamente cuando todas las preguntas tienen respuesta. +- Error de carga y error de envío se muestran por separado. +- No se anida un segundo landmark `main` dentro del layout del portal. + +### Historial + +- Promedio, mejor puntaje y cantidad aprobada se calculan desde los resultados + reales del endpoint. +- El umbral de aprobación visual es 70, alineado con la regla vigente. +- Tabla responsive con fecha, nivel, puntaje y estado. +- Carga, historial vacío y error remoto nunca se presentan simultáneamente. + +## Contratos de datos + +No hubo cambios de API ni migraciones. Se tiparon los contratos frontend: + +- `MisionAlumno`, `EntregaMision`, `DetalleMisionRespuesta`; +- `EvaluacionActiva`, `PreguntaEvaluacion`; +- `ResultadoEvaluacion`, `ResultadoRespuestaEvaluacion`. + +## QA ejecutado + +- pruebas focalizadas: 5 suites y 19 casos aprobados; +- arquitectura `shared/core/features`: aprobada; +- build Angular de producción: aprobado; +- bundle inicial: aproximadamente 908.1 kB, bajo el presupuesto de 1 MB; +- QA local autenticado con la cuenta de prueba documentada; +- Cursos validado con sus iconos, slots y assets sin roturas; +- Cursos, Misiones, Evaluaciones y Resultados sin overflow horizontal global a + 390 x 844 px; +- estados vacíos reales confirmados en Misiones, Evaluaciones y Resultados; +- permanecen sólo las advertencias Sass y `ts-jest` preexistentes. + +Comandos focalizados: + +```powershell +cd frontend-angular +npm test -- --runInBand src/app/features/alumno/pages/recursos/recursos.spec.ts src/app/features/misiones/pages/lista-misiones/lista-misiones.spec.ts src/app/features/misiones/pages/entregar-mision/entregar-mision.spec.ts src/app/features/evaluaciones/pages/examen-live/examen-live.spec.ts src/app/features/evaluaciones/pages/resultados-examen/resultados-examen.spec.ts +npm run check:architecture +npm run build +``` + +## Próximo bloque + +La siguiente reestructuración documentada es Herramientas IA: chatbot, +configuración del bot y estados de conversación. Después continúan Perfil, +Ranking y Comunidad; luego Tienda, Canjes y Mascota; finalmente Proyectos y +motores heredados. + +Cada bloque debe conservar sus contratos reales, resolver todos sus estados y +publicarse primero en rama/PR. Producción requiere autorización expresa. diff --git a/docs/sistema-visual-portal-alumno.md b/docs/sistema-visual-portal-alumno.md index c5e36090..54ee0881 100644 --- a/docs/sistema-visual-portal-alumno.md +++ b/docs/sistema-visual-portal-alumno.md @@ -144,6 +144,12 @@ marcado puede romper el resaltado aunque la navegación siga funcionando. NG-ZORRO aporta comportamiento y accesibilidad. La apariencia final se ajusta con clases locales y `:host ::ng-deep` cuando el encapsulado lo exige. +Los estados vacíos editoriales usan exclusivamente `app-estado-vacio`, que +compone `nz-empty` con el robot canónico, mensajes contextuales y acciones +reales. No se configura una imagen global de NG-ZORRO porque también afectaría +selects, tablas y controles compactos. El contrato, tamaños y reglas de +redacción están documentados en `docs/estados-vacios-daemon.md`. + ## Responsive Puntos comprobados durante el rediseño: @@ -164,7 +170,8 @@ En móvil: - No usar `linear-gradient` ni `radial-gradient` en sus módulos principales. - No crear un color de acento diferente para cada pantalla. - No usar glassmorphism como superficie principal. -- No usar robots o ilustraciones genéricas como relleno. +- No usar robots o ilustraciones genéricas como relleno. El robot canónico solo + representa un vacío confirmado mediante `app-estado-vacio`. - No ocultar información académica detrás de animaciones. - No convertir el portal en una plantilla administrativa. diff --git a/frontend-angular/package.json b/frontend-angular/package.json index 76b31c67..f9f5d240 100644 --- a/frontend-angular/package.json +++ b/frontend-angular/package.json @@ -10,8 +10,9 @@ "build:staging": "node scripts/generate-staging-environment.mjs && ng build --configuration staging", "watch": "ng build --watch --configuration development", "check:architecture": "node scripts/check-architecture.mjs", + "check:student-visual": "node scripts/check-student-visual.mjs", "test": "jest", - "test:ci": "npm run check:architecture && jest --runInBand --coverage", + "test:ci": "npm run check:architecture && npm run check:student-visual && jest --runInBand --coverage", "test:watch": "jest --watch", "e2e": "playwright test", "e2e:public": "playwright test --project=public" diff --git a/frontend-angular/public/img/empty/empty-robot.webp b/frontend-angular/public/img/empty/empty-robot.webp new file mode 100644 index 00000000..0114c345 Binary files /dev/null and b/frontend-angular/public/img/empty/empty-robot.webp differ diff --git a/frontend-angular/scripts/check-student-visual.mjs b/frontend-angular/scripts/check-student-visual.mjs new file mode 100644 index 00000000..faff8162 --- /dev/null +++ b/frontend-angular/scripts/check-student-visual.mjs @@ -0,0 +1,64 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { extname, join, relative } from 'node:path'; + +const root = new URL('../', import.meta.url).pathname.replace(/^\/(?:[A-Za-z]:)/, (value) => value.slice(1)); +const targets = [ + 'src/app/core/layouts/layout-alumno', + 'src/app/features/alumno', + 'src/app/features/misiones', + 'src/app/features/ranking', + 'src/app/features/tienda', + 'src/app/features/herramientas', + 'src/styles.scss', + 'src/styles/_components.scss', +]; + +const rules = [ + { name: 'degradado CSS', pattern: /(?:linear|radial)-gradient\s*\(/g }, + { name: 'degradado Tailwind', pattern: /\bbg-gradient(?:-[^\s"']+)?/g }, + { name: 'tipografía Outfit', pattern: /\bOutfit\b/g }, + { name: 'glassmorphism', pattern: /\b(?:backdrop-blur|shadow-glass)(?:-[^\s"']+)?/g }, + { name: 'desplazamiento vertical decorativo', pattern: /\bhover:-translate-y-(?!0\b)[^\s"']+/g }, + { name: 'radio mayor al sistema visual', pattern: /\brounded-\[(?:2\d|3\d|[4-9]\d)px\]/g }, +]; + +async function filesAt(path) { + const absolute = join(root, path); + const entries = await readdir(absolute, { withFileTypes: true }).catch(() => null); + + if (!entries) return [absolute]; + + const nested = await Promise.all(entries.map((entry) => { + const child = join(absolute, entry.name); + return entry.isDirectory() ? filesAt(relative(root, child)) : [child]; + })); + + return nested.flat(); +} + +const files = (await Promise.all(targets.map(filesAt))) + .flat() + .filter((file) => ['.html', '.scss', '.ts'].includes(extname(file))); +const violations = []; + +for (const file of files) { + const content = await readFile(file, 'utf8'); + const lines = content.split(/\r?\n/); + + for (const [index, line] of lines.entries()) { + for (const rule of rules) { + rule.pattern.lastIndex = 0; + if (rule.pattern.test(line)) { + violations.push(`${relative(root, file)}:${index + 1} — ${rule.name}`); + } + } + } +} + +if (violations.length) { + console.error('El portal alumno incumple el contrato visual:\n'); + console.error(violations.map((violation) => `- ${violation}`).join('\n')); + process.exit(1); +} + +console.log(`Contrato visual del portal alumno válido (${files.length} archivos revisados).`); diff --git a/frontend-angular/src/app/core/layouts/layout-alumno/layout-alumno.html b/frontend-angular/src/app/core/layouts/layout-alumno/layout-alumno.html index b6621122..7ba6f414 100644 --- a/frontend-angular/src/app/core/layouts/layout-alumno/layout-alumno.html +++ b/frontend-angular/src/app/core/layouts/layout-alumno/layout-alumno.html @@ -14,12 +14,6 @@ modo="alumno" rol="Estudiante" storageKey="daemon_sidebar_alumno_colapsado" - [perfilAvatar]="avatarUrl()" - [perfilEmail]="sesion.usuario()?.email || ''" - [perfilNombre]="sesion.usuario()?.nombre_completo || sesion.usuario()?.usuario || 'Cuenta activa'" - [perfilUsuario]="sesion.usuario()?.usuario || ''" - [perfilNivel]="perfilNivel()" - [perfilTokens]="perfilTokens()" [secciones]="seccionesSidebar" (logout)="salir()" /> diff --git a/frontend-angular/src/app/core/layouts/layout-alumno/layout-alumno.ts b/frontend-angular/src/app/core/layouts/layout-alumno/layout-alumno.ts index 3f8edcd3..7fca0dc8 100644 --- a/frontend-angular/src/app/core/layouts/layout-alumno/layout-alumno.ts +++ b/frontend-angular/src/app/core/layouts/layout-alumno/layout-alumno.ts @@ -3,32 +3,19 @@ import '../../../../../node_modules/ng-zorro-antd/descriptions/style/index.min.c import '../../../../../node_modules/ng-zorro-antd/statistic/style/index.min.css'; import '../../../../../node_modules/ng-zorro-antd/table/style/index.min.css'; import '../../../../../node_modules/ng-zorro-antd/upload/style/index.min.css'; -import { DatePipe } from '@angular/common'; import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { Title } from '@angular/platform-browser'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { - faArrowRightFromBracket, - faBell, faBookOpenReader, - faChevronDown, - faGear, faHouse, - faMagnifyingGlass, faRankingStar, faRocket, - faShieldHalved, faStore, faUser, } from '@fortawesome/free-solid-svg-icons'; -import { NzAvatarModule } from 'ng-zorro-antd/avatar'; -import { NzBadgeModule } from 'ng-zorro-antd/badge'; -import { NzButtonModule } from 'ng-zorro-antd/button'; -import { NzProgressModule } from 'ng-zorro-antd/progress'; import { EmailVerificationBanner } from '../../componentes/email-verification-banner/email-verification-banner'; -import { MonedaDaemon } from '../../../shared/componentes/moneda-daemon/moneda-daemon'; import { SidebarPortal } from '../sidebar-portal/sidebar-portal'; -import { Activos } from '../../servicios/activos'; import { Autenticacion } from '../../servicios/autenticacion'; import { CargaGlobal } from '../../servicios/carga-global'; import { BienestarDigital, EstadoBienestarDigital } from '../../servicios/bienestar-digital'; @@ -38,13 +25,12 @@ import { alumnoSidebarSections } from '../portal-sidebar.config'; import { temaPortalAlumno } from '../../dominio/tema-portal-alumno'; import { TourService } from '../../servicios/tour.service'; -import { NzDropDownModule } from 'ng-zorro-antd/dropdown'; import { TopbarAlumno } from '../topbar-alumno/topbar-alumno'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-layout-alumno', - imports: [RouterOutlet, RouterLink, RouterLinkActive, FontAwesomeModule, NzAvatarModule, NzBadgeModule, NzButtonModule, NzDropDownModule, NzProgressModule, EmailVerificationBanner, SidebarPortal, TopbarAlumno], + imports: [RouterOutlet, RouterLink, RouterLinkActive, FontAwesomeModule, EmailVerificationBanner, SidebarPortal, TopbarAlumno], templateUrl: './layout-alumno.html', styleUrl: './layout-alumno.scss', }) @@ -54,7 +40,6 @@ export class LayoutAlumno implements OnInit, OnDestroy { private readonly notificacionesService = inject(NotificacionesService); private readonly tourService = inject(TourService); private readonly router = inject(Router); - private readonly activos = inject(Activos); private readonly cargaGlobal = inject(CargaGlobal); private readonly titleService = inject(Title); private readonly bienestarService = inject(BienestarDigital); @@ -64,24 +49,14 @@ export class LayoutAlumno implements OnInit, OnDestroy { readonly seccionesSidebar = alumnoSidebarSections; readonly temaPortal = computed(() => temaPortalAlumno(this.sesion.usuario()?.nivel)); readonly iconos = { - buscar: faMagnifyingGlass, - campana: faBell, - rango: faShieldHalved, - desplegar: faChevronDown, - perfil: faUser, - config: faGear, - salir: faArrowRightFromBracket, + descanso: faBookOpenReader, inicio: faHouse, misiones: faRocket, tienda: faStore, ranking: faRankingStar, - descanso: faBookOpenReader, + perfil: faUser, }; - // Servicio de Notificaciones - notificaciones = this.notificacionesService.notificaciones; - notificacionesNoLeidas = this.notificacionesService.noLeidas; - constructor() { this.titleService.setTitle('Portal Alumno | DAEMON'); } @@ -97,58 +72,6 @@ export class LayoutAlumno implements OnInit, OnDestroy { if (this.latidoId !== null) window.clearInterval(this.latidoId); } - marcarComoLeidas(): void { - if (this.notificacionesNoLeidas() > 0) { - this.notificacionesService.marcarTodasComoLeidas().subscribe(); - } - } - - perfilDetalle(): string { - const usuario = this.sesion.usuario(); - return `Nivel ${this.nivelGamificacion()} - ${usuario?.tokens || 0} DAEMONS`; - } - - perfilNivel(): string | null { - const usuario = this.sesion.usuario(); - if (!usuario?.nivel) { - return null; - } - return `Nivel ${this.nivelGamificacion()} · ${usuario.nivel}`; - } - - perfilTokens(): number | null { - return this.sesion.usuario()?.tokens ?? null; - } - - nivelGamificacion(): number { - return this.sesion.usuario()?.nivel_gamificacion ?? 1; - } - - progresoNivel(): number { - return this.sesion.usuario()?.progreso_nivel?.progreso_porcentaje ?? 0; - } - - xpRestante(): number { - return this.sesion.usuario()?.progreso_nivel?.experiencia_restante ?? 100; - } - - readonly formatoNivel = (): string => `${this.nivelGamificacion()}`; - - avatarUrl(): string { - return this.activos.url(this.sesion.usuario()?.avatar); - } - - iniciales(): string { - const usuario = this.sesion.usuario(); - const base = usuario?.nombre_completo || usuario?.usuario || 'DAEMON'; - return base - .split(/\s+/) - .filter(Boolean) - .slice(0, 2) - .map((parte) => parte[0]?.toUpperCase()) - .join('') || 'D'; - } - salir(): void { const carga = this.cargaGlobal.mostrar('Cerrando sesion...'); diff --git a/frontend-angular/src/app/core/layouts/layout-docente/layout-docente.html b/frontend-angular/src/app/core/layouts/layout-docente/layout-docente.html index 90ec55bb..666535fe 100644 --- a/frontend-angular/src/app/core/layouts/layout-docente/layout-docente.html +++ b/frontend-angular/src/app/core/layouts/layout-docente/layout-docente.html @@ -6,12 +6,6 @@ modo="docente" rol="Docente" storageKey="daemon_sidebar_docente_colapsado" - [perfilAvatar]="avatarUrl()" - [perfilEmail]="sesion.usuario()?.email || ''" - [perfilNombre]="sesion.usuario()?.nombre_completo || sesion.usuario()?.usuario || 'Cuenta activa'" - [perfilUsuario]="sesion.usuario()?.usuario || ''" - [perfilNivel]="perfilNivel()" - [perfilTokens]="perfilTokens()" [secciones]="seccionesSidebar" (logout)="salir()" /> diff --git a/frontend-angular/src/app/core/layouts/layout-docente/layout-docente.ts b/frontend-angular/src/app/core/layouts/layout-docente/layout-docente.ts index 0576d126..6eafd0f8 100644 --- a/frontend-angular/src/app/core/layouts/layout-docente/layout-docente.ts +++ b/frontend-angular/src/app/core/layouts/layout-docente/layout-docente.ts @@ -75,18 +75,6 @@ export class LayoutDocente implements OnInit { return rol; } - perfilNivel(): string | null { - const usuario = this.sesion.usuario(); - if (!usuario?.nivel) { - return null; - } - return usuario.nivel.startsWith('Nivel') ? usuario.nivel : `Nivel ${usuario.nivel}`; - } - - perfilTokens(): number | null { - return this.sesion.usuario()?.tokens ?? null; - } - avatarUrl(): string { return this.activos.url(this.sesion.usuario()?.avatar); } diff --git a/frontend-angular/src/app/core/layouts/sidebar-portal/sidebar-portal.html b/frontend-angular/src/app/core/layouts/sidebar-portal/sidebar-portal.html index 037c3edc..c122afaf 100644 --- a/frontend-angular/src/app/core/layouts/sidebar-portal/sidebar-portal.html +++ b/frontend-angular/src/app/core/layouts/sidebar-portal/sidebar-portal.html @@ -11,72 +11,6 @@ aria-label="Cerrar menú" > - -
-
-
- - -
- -
- {{ modo === 'docente' ? 'Portal docente' : 'Portal estudiante' }} - {{ perfilNombre | titlecase }} - - {{ perfilUsuario ? '@' + perfilUsuario : rol }} - -
- - -
- -
    - @if (perfilEmail) { -
  • -
  • - } -
  • -
  • - @if (perfilNivel) { -
  • -
  • - } - @if (perfilTokens !== null) { -
  • - - - - Daemons - {{ perfilTokens | number }} -
  • - } -
-
-
- - } @else if (error()) { - } -
+ diff --git a/frontend-angular/src/app/features/misiones/pages/entregar-mision/entregar-mision.scss b/frontend-angular/src/app/features/misiones/pages/entregar-mision/entregar-mision.scss index 16c90323..43db1292 100644 --- a/frontend-angular/src/app/features/misiones/pages/entregar-mision/entregar-mision.scss +++ b/frontend-angular/src/app/features/misiones/pages/entregar-mision/entregar-mision.scss @@ -1,14 +1,302 @@ -:host { display: block; } -.delivery-page { max-width: none; } -.delivery-hero { background: #eaf5ff; border: 1px solid #d7eaff; border-radius: 16px; min-height: 170px; padding: clamp(1.4rem, 3vw, 2.2rem) !important; } -.delivery-hero span { color: #0958d9 !important; font-size: .68rem !important; font-weight: 700 !important; letter-spacing: .075em !important; } -.delivery-hero h1 { color: #172033 !important; font-size: clamp(1.8rem, 3.5vw, 2.8rem) !important; font-weight: 700 !important; } -.delivery-hero p { color: #667085 !important; } -:host ::ng-deep .delivery-card.ant-card { border: 1px solid #e4eaf2; border-radius: 14px; box-shadow: none; overflow: hidden; } -.delivery-reward { background: #f8fafc; border: 1px solid #edf1f5; border-radius: 10px; display: grid; gap: .7rem; grid-template-columns: 1fr 1px 1fr; margin-bottom: 1rem; padding: .9rem; } +:host { + display: block; + min-width: 0; +} + +.delivery-page { + display: grid; + gap: 1.1rem; + margin: 0 auto; + max-width: 1360px; + min-width: 0; +} + +.delivery-hero { + align-items: center; + background: #eef6ff; + border: 1px solid #d7eaff; + border-radius: 18px; + display: flex; + gap: 1.5rem; + justify-content: space-between; + min-height: 180px; + padding: clamp(1.4rem, 3vw, 2.2rem); +} + +.delivery-hero > div { + max-width: 760px; +} + +.delivery-hero span, +.delivery-form header span { + color: #0958d9; + display: block; + font-size: .68rem; + font-weight: 750; + letter-spacing: .075em; + text-transform: uppercase; +} + +.delivery-hero h1 { + color: #172033; + font-size: clamp(1.8rem, 3.5vw, 2.8rem); + letter-spacing: -.04em; + line-height: 1.08; + margin: .5rem 0; +} + +.delivery-hero p { + color: #667085; + line-height: 1.55; + margin: 0; +} + +.delivery-hero .daemon-btn.ant-btn { + align-items: center; + border-radius: 9px; + display: inline-flex; + flex: 0 0 auto; + font-weight: 700; + gap: .45rem; + min-height: 42px; +} + +.delivery-layout { + align-items: start; + display: grid; + gap: 1.1rem; + grid-template-columns: minmax(0, 2fr) minmax(280px, .8fr); +} + +.delivery-main, +.delivery-aside { + min-width: 0; +} + +.delivery-aside { + position: sticky; + top: 1rem; +} + +:host ::ng-deep .delivery-card.ant-card { + border: 1px solid #e4eaf2; + border-radius: 16px; + box-shadow: none; + overflow: hidden; +} + +:host ::ng-deep .delivery-card > .ant-card-body { + padding: clamp(1.1rem, 2.5vw, 1.5rem); +} + +.delivery-form { + display: grid; + gap: 1.25rem; +} + +.delivery-form > header { + align-items: flex-start; + border-bottom: 1px solid #edf0f5; + display: flex; + gap: 1rem; + justify-content: space-between; + padding-bottom: 1rem; +} + +.delivery-form h2, +.delivery-aside h2, +.delivery-locked-card h2 { + color: #172033; + font-size: 1.15rem; + margin: .25rem 0 0; +} + +.delivery-field { + display: grid; + gap: .5rem; +} + +.delivery-field > span { + color: #344054; + font-size: .82rem; + font-weight: 700; +} + +.delivery-field textarea { + background: #f8fafc; + border: 1px solid #d8e0eb; + border-radius: 12px; + color: #344054; + font: inherit; + font-size: 16px; + line-height: 1.55; + min-height: 180px; + outline: none; + padding: 1rem; + resize: vertical; + transition: border-color .18s ease, box-shadow .18s ease; + width: 100%; +} + +.delivery-field textarea:focus-visible { + border-color: #1677ff; + box-shadow: 0 0 0 3px rgba(22, 119, 255, .15); +} + +.selected-file { + align-items: center; + background: #edf5ff; + border: 1px solid #cfe3ff; + border-radius: 10px; + color: #174f91; + display: flex; + font-size: .8rem; + gap: .65rem; + padding: .75rem; +} + +.selected-file strong { + overflow-wrap: anywhere; +} + +.delivery-form footer { + border-top: 1px solid #edf0f5; + display: flex; + gap: .7rem; + justify-content: flex-end; + padding-top: 1rem; +} + +.delivery-form footer .ant-btn { + border-radius: 9px; + font-weight: 700; + min-height: 42px; + min-width: 130px; +} + +.teacher-feedback { + background: #fffaf0; + border: 1px solid #f5d08a; + border-left: 4px solid #f5a000; + border-radius: 0 10px 10px 0; + margin: 0; + padding: .9rem 1rem; +} + +.teacher-feedback small { + color: #9a5c00; + font-size: .64rem; + font-weight: 750; + letter-spacing: .06em; + text-transform: uppercase; +} + +.teacher-feedback p { + color: #654b21; + font-size: .8rem; + line-height: 1.5; + margin: .35rem 0 0; +} + +.delivery-reward { + background: #f8fafc; + border: 1px solid #edf1f5; + border-radius: 10px; + display: grid; + gap: .7rem; + grid-template-columns: 1fr 1px 1fr; + margin: 1rem 0; + padding: .9rem; +} + .delivery-reward span { display: grid; } -.delivery-reward strong { color: #0958d9; font-family: Inter, sans-serif; font-size: 1.05rem; font-weight: 700; } -.delivery-reward small { color: #667085; font-size: .64rem; font-weight: 600; } +.delivery-reward strong { color: #0958d9; font-size: 1.05rem; font-weight: 750; } +.delivery-reward small { color: #667085; font-size: .64rem; font-weight: 650; } .delivery-reward i { background: #dfe5ec; } -:host ::ng-deep textarea { border-radius: 9px !important; } -@media (max-width: 520px) { .delivery-reward { grid-template-columns: 1fr; } .delivery-reward i { height: 1px; } } + +.current-evidence { + align-items: center; + background: #edf5ff; + border-radius: 9px; + color: #0958d9; + display: flex; + font-size: .78rem; + font-weight: 700; + gap: .5rem; + justify-content: center; + margin-top: 1rem; + min-height: 42px; + padding: .65rem .8rem; + text-decoration: none; +} + +.delivery-locked-card { + text-align: center; +} + +.delivery-locked-card__icon { + align-items: center; + background: #edf9f1; + border-radius: 14px; + color: #0a7f3d; + display: inline-flex; + font-size: 1.2rem; + height: 56px; + justify-content: center; + width: 56px; +} + +.delivery-locked-card p { + color: #667085; + line-height: 1.6; + margin: .65rem auto 1rem; + max-width: 560px; +} + +.delivery-feedback { + border-radius: 12px; +} + +.delivery-loading, +.delivery-load-error { + background: #fff; + border: 1px solid #e4eaf2; + border-radius: 16px; + padding: 2rem; +} + +.delivery-load-error { + align-items: center; + background: #fff7f7; + border-color: #fecaca; + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr) auto auto; +} + +.delivery-load-error h1 { color: #7f1d1d; font-size: 1.1rem; margin: 0; } +.delivery-load-error p { color: #b42318; font-size: .82rem; margin: .3rem 0 0; } + +@media (max-width: 900px) { + .delivery-layout { grid-template-columns: 1fr; } + .delivery-aside { position: static; } +} + +@media (max-width: 640px) { + .delivery-hero { align-items: stretch; flex-direction: column; } + .delivery-hero .daemon-btn.ant-btn { justify-content: center; width: 100%; } + .delivery-load-error { align-items: stretch; grid-template-columns: 1fr; } + .delivery-load-error .ant-btn { justify-content: center; width: 100%; } + .delivery-form > header { flex-direction: column; } + .delivery-form footer { flex-direction: column-reverse; } + .delivery-form footer .ant-btn { width: 100%; } +} + +@media (max-width: 520px) { + .delivery-reward { grid-template-columns: 1fr; } + .delivery-reward i { height: 1px; } +} + +@media (prefers-reduced-motion: reduce) { + .delivery-field textarea { transition: none; } +} diff --git a/frontend-angular/src/app/features/misiones/pages/entregar-mision/entregar-mision.spec.ts b/frontend-angular/src/app/features/misiones/pages/entregar-mision/entregar-mision.spec.ts new file mode 100644 index 00000000..19a60468 --- /dev/null +++ b/frontend-angular/src/app/features/misiones/pages/entregar-mision/entregar-mision.spec.ts @@ -0,0 +1,64 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { DetalleMisionRespuesta, Mision } from '../../services/mision'; +import { EntregarMision } from './entregar-mision'; + +const base: DetalleMisionRespuesta = { + mision: { + id: 7, + titulo: 'Construye una solución', + recompensa: 35, + tipo_evidencia: 'archivo', + nivel_requerido: 'TEENS', + estado: 'activo', + }, + entrega: null, +}; + +describe('EntregarMision', () => { + const detalle = jest.fn(); + const entregar = jest.fn(); + + async function configurar(respuesta: DetalleMisionRespuesta): Promise { + detalle.mockReset().mockReturnValue(of(respuesta)); + entregar.mockReset().mockReturnValue(of({ id: 99, id_desafio: 7, id_alumno: 1, estado: 'pendiente' })); + await TestBed.configureTestingModule({ + imports: [EntregarMision], + providers: [ + provideRouter([]), + { provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => '7' } } } }, + { provide: Mision, useValue: { detalle, entregar } }, + ], + }).compileComponents(); + } + + it('bloquea el reenvío mientras la evidencia está pendiente', async () => { + await configurar({ + ...base, + entrega: { id: 8, id_desafio: 7, id_alumno: 1, estado: 'pendiente' }, + }); + const fixture = TestBed.createComponent(EntregarMision); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('form')).toBeNull(); + expect(element.querySelector('.delivery-locked-card')?.textContent).toContain('evidencia está en revisión'); + expect(fixture.componentInstance.entregaBloqueada()).toBe(true); + }); + + it('habilita una nueva versión sólo cuando el docente solicita cambios', async () => { + await configurar({ + ...base, + entrega: { id: 8, id_desafio: 7, id_alumno: 1, estado: 'rechazado', comentario_docente: 'Explica mejor el proceso.' }, + }); + const fixture = TestBed.createComponent(EntregarMision); + fixture.detectChanges(); + fixture.componentInstance.texto = 'Esta es mi versión corregida.'; + fixture.componentInstance.entregar(); + + expect(entregar).toHaveBeenCalledTimes(1); + const datos = entregar.mock.calls[0][1] as FormData; + expect(datos.get('texto')).toBe('Esta es mi versión corregida.'); + }); +}); diff --git a/frontend-angular/src/app/features/misiones/pages/entregar-mision/entregar-mision.ts b/frontend-angular/src/app/features/misiones/pages/entregar-mision/entregar-mision.ts index 9d293555..5946a5c0 100644 --- a/frontend-angular/src/app/features/misiones/pages/entregar-mision/entregar-mision.ts +++ b/frontend-angular/src/app/features/misiones/pages/entregar-mision/entregar-mision.ts @@ -1,15 +1,19 @@ -import { Component, signal , ChangeDetectionStrategy} from '@angular/core'; +import { UpperCasePipe } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, RouterLink } from '@angular/router'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faArrowLeft, faArrowUpRightFromSquare, faCheck, faFileArrowUp, faRotateRight } from '@fortawesome/free-solid-svg-icons'; +import { finalize } from 'rxjs'; import { NzAlertModule } from 'ng-zorro-antd/alert'; import { NzButtonModule } from 'ng-zorro-antd/button'; +import { NzCardModule } from 'ng-zorro-antd/card'; import { NzDescriptionsModule } from 'ng-zorro-antd/descriptions'; import { NzTagModule } from 'ng-zorro-antd/tag'; import { Cargando } from '../../../../shared/componentes/cargando/cargando'; import { MediaUploader } from '../../../../shared/componentes/media-uploader/media-uploader'; -import { Mision } from '../../services/mision'; -import { NzCardModule } from 'ng-zorro-antd/card'; -import { UpperCasePipe } from '@angular/common'; +import { DetalleMisionRespuesta, EntregaMision, Mision } from '../../services/mision'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -17,6 +21,7 @@ import { UpperCasePipe } from '@angular/common'; imports: [ FormsModule, RouterLink, + FontAwesomeModule, NzAlertModule, NzButtonModule, NzDescriptionsModule, @@ -30,18 +35,26 @@ import { UpperCasePipe } from '@angular/common'; styleUrl: './entregar-mision.scss', }) export class EntregarMision { - id: number; - detalle = signal(null); - cargando = signal(true); - enviando = signal(false); - mensaje = signal(''); - error = signal(''); - archivoNombre = signal(''); - uploadResetKey = signal(0); + readonly id: number; + readonly detalle = signal(null); + readonly cargando = signal(true); + readonly enviando = signal(false); + readonly mensaje = signal(''); + readonly error = signal(''); + readonly archivoNombre = signal(''); + readonly uploadResetKey = signal(0); + readonly iconos = { + actualizar: faRotateRight, + archivo: faFileArrowUp, + check: faCheck, + externo: faArrowUpRightFromSquare, + regresar: faArrowLeft, + }; + texto = ''; private archivo: File | null = null; - constructor(private route: ActivatedRoute, private mision: Mision) { + constructor(private readonly route: ActivatedRoute, private readonly mision: Mision) { this.id = Number(this.route.snapshot.paramMap.get('id')); this.cargar(); } @@ -54,36 +67,36 @@ export class EntregarMision { this.detalle.set(detalle); this.cargando.set(false); }, - error: (e) => { - this.error.set(e.error?.message ?? 'No se pudo cargar la mision.'); + error: (problema: unknown) => { + this.error.set(this.mensajeError(problema, 'No se pudo cargar la misión.')); this.cargando.set(false); }, }); } entregar(): void { + if (!this.puedeEntregar()) return; + this.enviando.set(true); this.mensaje.set(''); this.error.set(''); const datos = new FormData(); datos.append('texto', this.texto.trim()); - if (this.archivo) { - datos.append('archivo', this.archivo); - } + if (this.archivo) datos.append('archivo', this.archivo); - this.mision.entregar(this.id, datos).subscribe({ - next: () => { - this.mensaje.set('Entrega registrada.'); - this.limpiar(); - this.enviando.set(false); - this.cargar(); - }, - error: (e) => { - this.error.set(e.error?.message ?? 'No se pudo registrar la entrega.'); - this.enviando.set(false); - }, - }); + this.mision.entregar(this.id, datos) + .pipe(finalize(() => this.enviando.set(false))) + .subscribe({ + next: () => { + this.mensaje.set('Entrega registrada. Tu trabajo quedó en revisión.'); + this.limpiar(); + this.cargar(); + }, + error: (problema: unknown) => { + this.error.set(this.mensajeError(problema, 'No se pudo registrar la entrega. Tu evidencia permanece en esta pantalla.')); + }, + }); } seleccionarArchivo(archivo: File | null): void { @@ -92,7 +105,19 @@ export class EntregarMision { } puedeEntregar(): boolean { - return Boolean(this.texto.trim() || this.archivo); + return Boolean((this.texto.trim() || this.archivo) && !this.entregaBloqueada()); + } + + entregaBloqueada(): boolean { + const estado = this.detalle()?.entrega?.estado; + return estado === 'pendiente' || estado === 'aprobado'; + } + + estadoLabel(entrega: EntregaMision | null): string { + if (!entrega) return 'Sin entrega'; + if (entrega.estado === 'aprobado') return 'Aprobada'; + if (entrega.estado === 'pendiente') return 'En revisión'; + return 'Requiere cambios'; } limpiar(): void { @@ -101,4 +126,9 @@ export class EntregarMision { this.archivoNombre.set(''); this.uploadResetKey.update((valor) => valor + 1); } + + private mensajeError(problema: unknown, fallback: string): string { + if (problema instanceof HttpErrorResponse) return problema.error?.message ?? fallback; + return fallback; + } } diff --git a/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.html b/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.html index 0cfb07fb..52a5b82e 100644 --- a/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.html +++ b/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.html @@ -1,58 +1,136 @@ -
+
-
- Centro de misiones -

Convierte retos
en progreso real.

+
+ +

Convierte retos en progreso real

Cada misión aprobada suma la misma recompensa a tu XP permanente y a tu saldo de DAEMONS.

+ @if (!cargando() && !error()) { + {{ misiones().length }} {{ misiones().length === 1 ? 'misión en tu ruta' : 'misiones en tu ruta' }} + }
- -
- @if (error()) { - - } - @if (cargando()) { - +
+ +
+ @for (item of [1, 2, 3]; track item) { + + } +
+
+ } @else if (error()) { + } @else if (misiones().length) { -
- @for (mision of misiones(); track mision.id; let index = $index) { - -
- {{ (index + 1).toString().padStart(2, '0') }} - - - {{ estadoLabel(mision) }} - -
+ -
- {{ mision.nivel_requerido }} · {{ mision.tipo_evidencia }} -

{{ mision.titulo }}

-

{{ mision.descripcion || 'Tu docente preparó un desafío para poner a prueba lo que aprendiste.' }}

-
+ @if (misionesFiltradas().length) { +
+ @for (mision of misionesFiltradas(); track mision.id; let index = $index) { +
+
+ {{ (index + 1).toString().padStart(2, '0') }} + + +
-
- +{{ mision.recompensa }}XP - - +{{ mision.recompensa }}DAEMONS -
+
+ {{ mision.nivel_requerido }} · {{ mision.tipo_evidencia }} +

{{ mision.titulo }}

+

{{ mision.descripcion || 'Tu docente preparó un desafío para poner a prueba lo que aprendiste.' }}

+
- - - } -
+
+ + + +{{ mision.recompensa }}DAEMONS +
+ + + + } +
+ } @else { + + + + } } @else { -
+ + + Volver a mis cursos + } -
+ diff --git a/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.scss b/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.scss index 78aa869a..f9cfefb9 100644 --- a/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.scss +++ b/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.scss @@ -1,53 +1,414 @@ -:host { display: block; } -.missions-page { display: grid; gap: 1.25rem; } +:host { + display: block; + min-width: 0; +} + +.missions-page { + --missions-border: var(--daemon-border, #e4eaf2); + --missions-ink: var(--daemon-ink, #172033); + --missions-muted: var(--daemon-muted, #667085); + --missions-primary: var(--daemon-primary, #1677ff); + container-name: missions-page; + container-type: inline-size; + display: grid; + gap: 1.1rem; + margin: 0 auto; + max-width: 1360px; + min-width: 0; +} .missions-hero { - background: #eaf5ff; + align-items: center; + background: #eef6ff; border: 1px solid #d7eaff; - border-radius: 16px; - min-height: 210px; + border-radius: 18px; + display: grid; + gap: 1.25rem; + grid-template-columns: minmax(0, 1fr) minmax(220px, 340px); + min-height: 230px; overflow: hidden; - padding: clamp(1.5rem, 3vw, 2.4rem); + padding: clamp(1.35rem, 3vw, 2.4rem); + position: relative; +} + +.missions-hero__copy { + max-width: 680px; position: relative; + z-index: 2; +} + +.page-kicker { + align-items: center; + color: #0958d9; + display: inline-flex; + font-size: .68rem; + font-weight: 800; + gap: .45rem; + letter-spacing: .08em; + text-transform: uppercase; +} + +.missions-hero h1 { + color: var(--missions-ink); + font-size: clamp(2rem, 4vw, 3rem); + letter-spacing: -.045em; + line-height: 1.06; + margin: .7rem 0; +} + +.missions-hero p { + color: var(--missions-muted); + line-height: 1.6; + margin: 0; + max-width: 610px; +} + +.missions-hero__meta { + background: #fff; + border: 1px solid #cfe3ff; + border-radius: 999px; + color: #174f91; + display: inline-flex; + font-size: .7rem; + font-weight: 750; + margin-top: .9rem; + padding: .38rem .7rem; +} + +.missions-hero__illustration { + align-self: end; + justify-self: end; + margin-bottom: -2.5rem; + max-width: 310px; + width: 100%; +} + +.refresh-button.ant-btn { + align-items: center; + background: #fff; + border-color: #cfe3ff; + border-radius: 10px; + color: #0958d9; + display: inline-flex; + font-weight: 700; + gap: .45rem; + min-height: 42px; + position: absolute; + right: 1.25rem; + top: 1.25rem; + z-index: 3; +} + +.missions-toolbar { + align-items: center; + background: #fff; + border: 1px solid var(--missions-border); + border-radius: 16px; + display: flex; + gap: .35rem; + overflow-x: auto; + padding: .7rem; + scrollbar-width: none; +} + +.missions-toolbar::-webkit-scrollbar { + display: none; +} + +.mission-filter { + align-items: center; + background: transparent; + border: 1px solid transparent; + border-radius: 10px; + color: #526174; + cursor: pointer; + display: inline-flex; + flex: 0 0 auto; + font: inherit; + font-size: .76rem; + font-weight: 700; + gap: .45rem; + min-height: 42px; + padding: .55rem .72rem; +} + +.mission-filter strong { + align-items: center; + background: #eef2f7; + border-radius: 999px; + display: inline-flex; + font-size: .63rem; + height: 22px; + justify-content: center; + min-width: 22px; + padding-inline: .3rem; +} + +.mission-filter:hover { + background: #f5f8fc; + border-color: #dce3ed; + color: var(--missions-ink); +} + +.mission-filter--active { + background: #0958d9; + border-color: #0958d9; + color: #fff; +} + +.mission-filter--active:hover { + background: #074f9f; + border-color: #074f9f; + color: #fff; +} + +.mission-filter--active strong { + background: rgba(255, 255, 255, .2); +} + +.missions-grid, +.missions-loading__grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.mission-card { + background: #fff; + border: 1px solid var(--missions-border); + border-radius: 16px; + display: flex; + flex-direction: column; + min-height: 360px; + min-width: 0; + padding: 1.2rem; + transition: border-color .18s ease, box-shadow .18s ease; +} + +.mission-card:hover { + border-color: #b8d8ff; + box-shadow: 0 8px 22px rgba(25, 55, 95, .08); +} + +.mission-card[data-state='approved'] { border-top: 4px solid #12a150; } +.mission-card[data-state='pending'] { border-top: 4px solid #f5a000; } +.mission-card[data-state='rejected'] { border-top: 4px solid #dc2626; } + +.mission-card > header { + align-items: center; + display: flex; + justify-content: space-between; +} + +.mission-index { + color: #c2c9d3; + font-size: 1.15rem; + font-weight: 750; +} + +.status-pill { + align-items: center; + background: #edf5ff; + border-radius: 8px; + color: #0958d9; + display: inline-flex; + font-size: .62rem; + font-weight: 750; + gap: .35rem; + letter-spacing: .025em; + padding: .4rem .55rem; + text-transform: uppercase; } -.missions-hero > div:first-child { max-width: 680px; position: relative; z-index: 2; } -.page-kicker { align-items: center; color: #0958d9; display: inline-flex; font-size: .68rem; font-weight: 700; gap: .45rem; letter-spacing: .075em; text-transform: uppercase; } -.missions-hero h1 { color: #172033; font-size: clamp(2rem, 4vw, 3.25rem); line-height: 1.06; margin: .75rem 0; } -.missions-hero h1 span { color: #1677ff; } -.missions-hero p { color: #667085; line-height: 1.6; margin: 0; max-width: 600px; } -.refresh-button { align-items: center; background: #fff; border: 1px solid #cfe3ff; border-radius: 8px; color: #0958d9; display: inline-flex; font-weight: 600; gap: .45rem; min-height: 40px; position: absolute; right: 1.5rem; top: 1.5rem; z-index: 3; } -.refresh-button:hover { background: #f6faff; border-color: #9ccaff; color: #0958d9; } -.hero-geometry { bottom: -48px; height: 200px; opacity: .55; position: absolute; right: 7%; width: 200px; } -.hero-geometry span { border: 1px solid #9ccaff; border-radius: 24px; inset: 16%; position: absolute; transform: rotate(24deg); } -.hero-geometry span:nth-child(2) { inset: 30%; transform: rotate(8deg); } -.hero-geometry span:nth-child(3) { align-items: center; background: #1677ff; border: 0; color: #fff; display: flex; font-size: 1rem; inset: 39%; justify-content: center; transform: rotate(15deg); } - -.missions-grid { display: grid; gap: 1rem; grid-template-columns: repeat(3, minmax(0, 1fr)); } -:host ::ng-deep .mission-card.ant-card { background: #fff; border: 1px solid #e4eaf2; border-radius: 14px; box-shadow: none; min-height: 360px; overflow: hidden; transition: border-color .18s ease, box-shadow .18s ease; } -:host ::ng-deep .mission-card.ant-card-hoverable:hover { border-color: #b8d8ff; box-shadow: 0 8px 22px rgba(25, 55, 95, .08); transform: none; } -:host ::ng-deep .mission-card > .ant-card-body { display: flex; flex-direction: column; height: 100%; padding: 1.25rem; } -:host ::ng-deep .mission-card--approved.ant-card { border-top: 4px solid #12a150; } -:host ::ng-deep .mission-card--pending.ant-card { border-top: 4px solid #f5a000; } -.mission-card header { align-items: center; display: flex; justify-content: space-between; } -.mission-index { color: #c2c9d3; font-family: Inter, sans-serif; font-size: 1.2rem; font-weight: 700; } -.status-pill { align-items: center; background: #edf5ff; border-radius: 7px; color: #0958d9; display: inline-flex; font-size: .64rem; font-weight: 700; gap: .35rem; letter-spacing: .025em; padding: .38rem .55rem; text-transform: uppercase; } + .status-pill[data-state='approved'] { background: #edf9f1; color: #0a7f3d; } .status-pill[data-state='pending'] { background: #fffbeb; color: #a86500; } -.mission-copy { margin-top: 1.15rem; } -.mission-copy small { color: #1677ff; font-size: .65rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; } -.mission-copy h2 { color: #172033; font-size: 1.15rem; line-height: 1.3; margin: .5rem 0 .6rem; } -.mission-copy p { color: #667085; font-size: .8rem; line-height: 1.55; margin: 0; } -.dual-reward { align-items: center; background: #f8fafc; border: 1px solid #edf1f5; border-radius: 10px; display: grid; gap: .4rem; grid-template-columns: 1fr 1px 1fr; margin: 1.2rem 0; padding: .75rem; } -.dual-reward > span { align-items: center; color: #667085; display: grid; gap: .1rem .4rem; grid-template-columns: auto 1fr; } +.status-pill[data-state='rejected'] { background: #fff1f1; color: #b42318; } + +.mission-copy { + margin-top: 1.1rem; +} + +.mission-copy small { + color: #0958d9; + font-size: .64rem; + font-weight: 750; + letter-spacing: .07em; + text-transform: uppercase; +} + +.mission-copy h2 { + color: var(--missions-ink); + font-size: 1.15rem; + line-height: 1.3; + margin: .5rem 0 .6rem; +} + +.mission-copy p { + color: var(--missions-muted); + font-size: .8rem; + line-height: 1.55; + margin: 0; +} + +.dual-reward { + align-items: center; + background: #f8fafc; + border: 1px solid #edf1f5; + border-radius: 10px; + display: grid; + gap: .45rem; + grid-template-columns: 1fr 1px 1fr; + margin: 1.2rem 0; + padding: .75rem; +} + +.dual-reward > span { + align-items: center; + color: #667085; + display: grid; + gap: .1rem .4rem; + grid-template-columns: auto 1fr; +} + .dual-reward > span > :first-child { grid-row: span 2; } .dual-reward strong { color: #344054; font-size: .86rem; } -.dual-reward small { color: #8a94a3; font-size: .58rem; font-weight: 700; letter-spacing: .05em; } -.dual-reward i { background: #dfe5ec; height: 28px; } -.mission-card footer { align-items: center; display: flex; gap: .55rem; justify-content: space-between; margin-top: auto; } -:host ::ng-deep .mission-card footer .daemon-btn.ant-btn { border-radius: 8px; flex: 1; } -.detail-link { align-items: center; background: #f3f6fa; border: 1px solid #e4eaf2; border-radius: 8px; color: #667085; display: flex; flex: 0 0 40px; height: 40px; justify-content: center; } +.dual-reward small { color: #8a94a3; font-size: .56rem; font-weight: 750; letter-spacing: .04em; } +.dual-reward i { background: #dfe5ec; height: 30px; } + +.mission-card footer { + align-items: center; + display: flex; + gap: .55rem; + margin-top: auto; +} + +.mission-primary-action.ant-btn { + align-items: center; + border-radius: 9px; + display: inline-flex; + flex: 1; + font-weight: 700; + gap: .45rem; + justify-content: center; + min-height: 42px; +} + +.detail-link { + align-items: center; + background: #f3f6fa; + border: 1px solid var(--missions-border); + border-radius: 9px; + color: #667085; + display: flex; + flex: 0 0 42px; + height: 42px; + justify-content: center; +} + .detail-link:hover { background: #edf5ff; color: #1677ff; } -.missions-state { background: #fff; border: 1px solid #e4eaf2; border-radius: 14px; padding: 2rem; } -@media (max-width: 1080px) { .missions-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 720px) { .refresh-button { margin-top: 1.2rem; position: relative; right: auto; top: auto; } .hero-geometry { opacity: .35; right: -60px; } .missions-grid { grid-template-columns: 1fr; } } +.missions-error { + align-items: center; + background: #fff7f7; + border: 1px solid #fecaca; + border-radius: 16px; + display: grid; + gap: 1rem; + grid-template-columns: auto minmax(0, 1fr) auto; + padding: 1.2rem; +} + +.missions-error__icon { + align-items: center; + background: #fee2e2; + border-radius: 12px; + color: #b42318; + display: inline-flex; + height: 48px; + justify-content: center; + width: 48px; +} + +.missions-error h2 { color: #7f1d1d; font-size: 1rem; margin: 0; } +.missions-error p { color: #b42318; font-size: .82rem; margin: .25rem 0 0; } +.missions-state { background: #fff; border: 1px solid var(--missions-border); border-radius: 16px; } + +.missions-loading { + display: grid; + gap: 1rem; +} + +.missions-loading__toolbar, +.mission-skeleton { + animation: mission-pulse 1.4s ease-in-out infinite; + background: #fff; + border: 1px solid var(--missions-border); + border-radius: 16px; +} + +.missions-loading__toolbar { height: 66px; } + +.mission-skeleton { + display: grid; + gap: .8rem; + min-height: 340px; + padding: 1.2rem; +} + +.mission-skeleton span { background: #edf1f6; border-radius: 8px; display: block; height: 18px; } +.mission-skeleton span:nth-child(1) { width: 38%; } +.mission-skeleton span:nth-child(2) { height: 30px; margin-top: 1rem; width: 72%; } +.mission-skeleton span:nth-child(3) { height: 72px; } +.mission-skeleton span:nth-child(4) { height: 70px; margin-top: auto; } + +@keyframes mission-pulse { + 0%, 100% { opacity: .62; } + 50% { opacity: 1; } +} + +@media (max-width: 1080px) { + .missions-grid, + .missions-loading__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@container missions-page (max-width: 760px) { + .missions-hero { grid-template-columns: minmax(0, 1fr) minmax(180px, 240px); } + .missions-grid, + .missions-loading__grid { grid-template-columns: 1fr; } +} + +@media (max-width: 680px) { + .missions-hero { + grid-template-columns: 1fr; + min-height: 390px; + padding: 1.1rem 1.1rem 0; + } + + .missions-hero__illustration { + justify-self: center; + margin-bottom: -2rem; + max-width: 230px; + } + + .refresh-button.ant-btn { + margin-top: .8rem; + position: static; + width: max-content; + } + + .missions-error { align-items: stretch; grid-template-columns: 1fr; } + .missions-error .ant-btn { justify-content: center; width: 100%; } + .missions-grid, + .missions-loading__grid { grid-template-columns: 1fr; } + .mission-filter { min-height: 40px; } +} + +@media (prefers-reduced-motion: reduce) { + .mission-card, + .mission-filter, + .missions-loading__toolbar, + .mission-skeleton { + animation: none; + transition: none; + } +} diff --git a/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.spec.ts b/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.spec.ts new file mode 100644 index 00000000..5987ef11 --- /dev/null +++ b/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.spec.ts @@ -0,0 +1,61 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { Mision, MisionAlumno } from '../../services/mision'; +import { ListaMisiones } from './lista-misiones'; + +const misiones: MisionAlumno[] = [ + { id: 1, titulo: 'Explora', recompensa: 20, tipo_evidencia: 'texto', nivel_requerido: 'KIDS', estado: 'activo' }, + { id: 2, titulo: 'En revisión', recompensa: 30, tipo_evidencia: 'archivo', nivel_requerido: 'TEENS', estado: 'activo', entrega: { id: 20, id_desafio: 2, id_alumno: 1, estado: 'pendiente' } }, + { id: 3, titulo: 'Completada', recompensa: 40, tipo_evidencia: 'imagen', nivel_requerido: 'TODOS', estado: 'activo', entrega: { id: 30, id_desafio: 3, id_alumno: 1, estado: 'aprobado' } }, + { id: 4, titulo: 'Corrige', recompensa: 50, tipo_evidencia: 'video', nivel_requerido: 'TEENS', estado: 'activo', entrega: { id: 40, id_desafio: 4, id_alumno: 1, estado: 'rechazado' } }, +]; + +describe('ListaMisiones', () => { + const listar = jest.fn(); + + beforeEach(async () => { + listar.mockReset().mockReturnValue(of(misiones)); + await TestBed.configureTestingModule({ + imports: [ListaMisiones], + providers: [provideRouter([]), { provide: Mision, useValue: { listar } }], + }).compileComponents(); + }); + + it('presenta estados reales, iconos y el slot reemplazable del hero', () => { + const fixture = TestBed.createComponent(ListaMisiones); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('main')).toBeNull(); + expect(element.querySelectorAll('.mission-card')).toHaveLength(4); + expect(element.querySelectorAll('.mission-filter')).toHaveLength(5); + expect(element.querySelectorAll('.mission-filter fa-icon')).toHaveLength(5); + expect(element.querySelector('[data-asset-name="missions-route-guide.webp"]')).not.toBeNull(); + expect(element.querySelector('.mission-card[data-state="rejected"]')?.textContent).toContain('Por corregir'); + }); + + it('filtra sin perder la acción correcta para una entrega rechazada', () => { + const fixture = TestBed.createComponent(ListaMisiones); + fixture.detectChanges(); + fixture.componentInstance.seleccionarFiltro('rejected'); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelectorAll('.mission-card')).toHaveLength(1); + expect(element.querySelector('.mission-card h2')?.textContent).toContain('Corrige'); + expect(element.querySelector('.mission-primary-action')?.getAttribute('href')) + .toBe('/alumno/misiones/4/entregar'); + }); + + it('diferencia un error remoto del estado académico vacío', () => { + listar.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 503 }))); + const fixture = TestBed.createComponent(ListaMisiones); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('.missions-error')?.textContent).toContain('No pudimos abrir tu ruta'); + expect(element.querySelector('.missions-state')).toBeNull(); + }); +}); diff --git a/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.ts b/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.ts index ec87dc8e..0a366858 100644 --- a/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.ts +++ b/frontend-angular/src/app/features/misiones/pages/lista-misiones/lista-misiones.ts @@ -1,29 +1,81 @@ -import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; -import { faArrowRight, faBolt, faCheck, faClock, faRocket, faRotateRight, faStar } from '@fortawesome/free-solid-svg-icons'; -import { NzAlertModule } from 'ng-zorro-antd/alert'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { + faArrowRight, + faBolt, + faCircleCheck, + faClock, + faFlag, + faGrip, + faRotateRight, + faRocket, + faTriangleExclamation, + faWrench, +} from '@fortawesome/free-solid-svg-icons'; import { NzButtonModule } from 'ng-zorro-antd/button'; -import { NzCardModule } from 'ng-zorro-antd/card'; -import { Cargando } from '../../../../shared/componentes/cargando/cargando'; import { EstadoVacio } from '../../../../shared/componentes/estado-vacio/estado-vacio'; +import { IllustrationSlot } from '../../../../shared/componentes/illustration-slot/illustration-slot'; import { MonedaDaemon } from '../../../../shared/componentes/moneda-daemon/moneda-daemon'; -import { Mision } from '../../services/mision'; +import { Mision, MisionAlumno } from '../../services/mision'; + +type EstadoMisionVista = 'approved' | 'pending' | 'rejected' | 'available'; +type FiltroMision = 'all' | EstadoMisionVista; + +interface OpcionMision { + value: FiltroMision; + label: string; + count: number; + icon: IconDefinition; +} @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-lista-misiones', - imports: [RouterLink, FontAwesomeModule, NzAlertModule, NzButtonModule, NzCardModule, Cargando, EstadoVacio, MonedaDaemon], + imports: [RouterLink, FontAwesomeModule, NzButtonModule, EstadoVacio, IllustrationSlot, MonedaDaemon], templateUrl: './lista-misiones.html', styleUrl: './lista-misiones.scss', }) export class ListaMisiones { - readonly misiones = signal([]); + readonly misiones = signal([]); readonly cargando = signal(true); readonly error = signal(''); - readonly iconos = { flecha: faArrowRight, energia: faBolt, check: faCheck, reloj: faClock, cohete: faRocket, actualizar: faRotateRight, estrella: faStar }; + readonly filtro = signal('all'); + + readonly iconos = { + actualizar: faRotateRight, + check: faCircleCheck, + energia: faBolt, + error: faTriangleExclamation, + flecha: faArrowRight, + lista: faGrip, + pendiente: faClock, + reparar: faWrench, + ruta: faFlag, + cohete: faRocket, + }; - constructor(private mision: Mision) { + readonly filtros = computed(() => { + const misiones = this.misiones(); + return [ + { value: 'all', label: 'Todas', count: misiones.length, icon: this.iconos.lista }, + { value: 'available', label: 'Disponibles', count: this.contar('available'), icon: this.iconos.ruta }, + { value: 'pending', label: 'En revisión', count: this.contar('pending'), icon: this.iconos.pendiente }, + { value: 'approved', label: 'Completadas', count: this.contar('approved'), icon: this.iconos.check }, + { value: 'rejected', label: 'Por corregir', count: this.contar('rejected'), icon: this.iconos.reparar }, + ]; + }); + + readonly misionesFiltradas = computed(() => { + const filtro = this.filtro(); + return filtro === 'all' + ? this.misiones() + : this.misiones().filter((mision) => this.estado(mision) === filtro); + }); + + constructor(private readonly mision: Mision) { this.cargar(); } @@ -32,23 +84,47 @@ export class ListaMisiones { this.error.set(''); this.mision.listar().subscribe({ next: (misiones) => { - this.misiones.set(misiones as any[]); + this.misiones.set(misiones); this.cargando.set(false); }, - error: (e) => { - this.error.set(e.error?.message ?? 'No se pudieron cargar las misiones.'); + error: (problema: unknown) => { + this.error.set(this.mensajeError(problema)); this.cargando.set(false); }, }); } - estado(mision: any): 'approved' | 'pending' | 'available' { + seleccionarFiltro(filtro: FiltroMision): void { + this.filtro.set(filtro); + } + + estado(mision: MisionAlumno): EstadoMisionVista { if (mision.entrega?.estado === 'aprobado') return 'approved'; if (mision.entrega?.estado === 'pendiente') return 'pending'; + if (mision.entrega?.estado === 'rechazado') return 'rejected'; return 'available'; } - estadoLabel(mision: any): string { - return this.estado(mision) === 'approved' ? 'Completada' : this.estado(mision) === 'pending' ? 'En revisión' : 'Disponible'; + estadoLabel(mision: MisionAlumno): string { + const estado = this.estado(mision); + if (estado === 'approved') return 'Completada'; + if (estado === 'pending') return 'En revisión'; + if (estado === 'rejected') return 'Por corregir'; + return 'Disponible'; + } + + etiquetaAccion(mision: MisionAlumno): string { + return this.estado(mision) === 'rejected' ? 'Corregir entrega' : 'Comenzar misión'; + } + + private contar(estado: EstadoMisionVista): number { + return this.misiones().filter((mision) => this.estado(mision) === estado).length; + } + + private mensajeError(problema: unknown): string { + if (problema instanceof HttpErrorResponse) { + return problema.error?.message ?? 'No pudimos cargar tus misiones. Revisa tu conexión e inténtalo nuevamente.'; + } + return 'No pudimos cargar tus misiones. Revisa tu conexión e inténtalo nuevamente.'; } } diff --git a/frontend-angular/src/app/features/misiones/services/mision.ts b/frontend-angular/src/app/features/misiones/services/mision.ts index b9d9bcdb..2e01e142 100644 --- a/frontend-angular/src/app/features/misiones/services/mision.ts +++ b/frontend-angular/src/app/features/misiones/services/mision.ts @@ -1,17 +1,49 @@ import { Injectable } from '@angular/core'; import { Api } from '../../../core/servicios/api'; +export type EstadoEntregaMision = 'pendiente' | 'aprobado' | 'rechazado'; + +export interface EntregaMision { + id: number; + id_desafio: number; + id_alumno: number; + archivo_url?: string | null; + estado: EstadoEntregaMision; + calificacion?: number | null; + fecha_entrega?: string | null; + fecha_revision?: string | null; + comentario_docente?: string | null; +} + +export interface MisionAlumno { + id: number; + titulo: string; + descripcion?: string | null; + recompensa: number; + tipo_evidencia: string; + nivel_requerido: string; + estado: string; + fecha_creacion?: string | null; + es_mision_nivel?: boolean; + entrega?: EntregaMision | null; +} + +export interface DetalleMisionRespuesta { + mision: MisionAlumno; + entrega: EntregaMision | null; +} + @Injectable({ providedIn: 'root', }) export class Mision { constructor(private api: Api) {} - listar() { return this.api.get('/misiones'); } - detalle(id: number) { return this.api.get(`/misiones/${id}`); } + listar() { return this.api.get('/misiones'); } + detalle(id: number) { return this.api.get(`/misiones/${id}`); } crear(datos: unknown) { return this.api.post('/misiones', datos); } actualizar(id: number, datos: unknown) { return this.api.put(`/misiones/${id}`, datos); } eliminar(id: number) { return this.api.delete(`/misiones/${id}`); } - entregar(id: number, datos: unknown) { return this.api.post(`/misiones/${id}/entregar`, datos); } + entregar(id: number, datos: unknown) { return this.api.post(`/misiones/${id}/entregar`, datos); } entregas() { return this.api.get('/misiones/entregas'); } revisar(id: number, datos: unknown) { return this.api.post(`/misiones/entregas/${id}/revisar`, datos); } } diff --git a/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.html b/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.html index 0736d1b1..d2ae7bee 100644 --- a/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.html +++ b/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.html @@ -1,12 +1,50 @@ -
- -

{{ titulo }}

-

{{ descripcion }}

-
- -
+
+ + + + + + + +
+

{{ titulo }}

+

{{ descripcion }}

+
+
+ + +
+ +
+
diff --git a/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.scss b/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.scss index 8e4ca537..1165761d 100644 --- a/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.scss +++ b/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.scss @@ -1,22 +1,187 @@ -:host { display: block; min-width: 0; width: 100%; } +:host { + display: block; + min-width: 0; + width: 100%; +} .empty-state { align-content: center; - background: #ffffff; - border: 1px dashed #cbd5e1; - border-radius: 14px; + background: var(--daemon-surface, #ffffff); + border: 1px solid var(--daemon-border, #e4eaf2); + border-radius: 18px; + display: flex; + font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + justify-content: center; + min-height: 350px; + padding: clamp(1.5rem, 4vw, 2.75rem); + text-align: center; + width: 100%; +} + +nz-empty { + margin: 0; + max-width: 560px; + width: 100%; +} + +/* NG-ZORRO reserva una altura fija para sus ilustraciones predeterminadas. + * Esta plantilla aporta una imagen responsiva propia, por eso el contenedor + * interno debe crecer con ella y no recortarla. */ +:host ::ng-deep .ant-empty-image { + height: auto; + margin-bottom: .85rem; +} + +:host ::ng-deep .ant-empty-description { + color: inherit; +} + +:host ::ng-deep .ant-empty-footer { + margin-top: 1rem; +} + +.empty-state__art { + align-items: center; + aspect-ratio: 1; + display: flex; + justify-content: center; + margin: 0 auto; + max-width: 230px; + width: min(46vw, 230px); +} + +.empty-state__art img { + display: block; + height: 100%; + object-fit: contain; + pointer-events: none; + user-select: none; + width: 100%; +} + +.empty-state__fallback { + align-items: center; + background: #edf7ff; + border: 1px solid #cfe8f6; + border-radius: 24px; + color: var(--daemon-primary, #1677ff); + display: inline-flex; + height: 112px; + justify-content: center; + width: 112px; +} + +.empty-state__fallback svg { + height: 54px; + width: 54px; +} + +.empty-state__copy { display: grid; + gap: .45rem; justify-items: center; +} + +.empty-state h3 { + color: var(--daemon-ink, #172033); + font-size: clamp(1.12rem, 2vw, 1.35rem); + font-weight: 800; + letter-spacing: -.02em; + line-height: 1.25; + margin: 0; +} + +.empty-state p { + color: var(--daemon-muted, #667085); + font-size: .92rem; + line-height: 1.55; + margin: 0; + max-width: 470px; + overflow-wrap: anywhere; +} + +.empty-state__actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: .65rem; + justify-content: center; + margin-top: .35rem; +} + +.empty-state__actions:empty { + display: none; +} + +.empty-state--compact { min-height: 270px; - padding: clamp(1.5rem, 5vw, 3rem); - text-align: center; - width: 100%; + padding: 1.5rem; +} + +.empty-state--compact .empty-state__art { + max-width: 155px; + width: min(38vw, 155px); +} + +.empty-state--mini { + background: transparent; + border: 0; + min-height: 170px; + padding: 1rem .75rem; +} + +.empty-state--mini .empty-state__art { + max-width: 92px; + width: 92px; } -.empty-state--compact { min-height: 190px; padding: 1.5rem; } -.empty-state__icon { align-items: center; background: #edf5ff; border: 1px solid #cfe3ff; border-radius: 14px; color: #1677ff; display: inline-flex; height: 58px; justify-content: center; width: 58px; } -.empty-state__icon svg { height: 28px; width: 28px; } -.empty-state h3 { color: #172033; font-size: clamp(1.15rem, 3vw, 1.4rem); font-weight: 750; letter-spacing: -.02em; margin: 1rem 0 .35rem; } -.empty-state p { color: #667085; font-size: .92rem; line-height: 1.55; margin: 0; max-width: 440px; overflow-wrap: anywhere; } -.empty-state__actions { display: flex; flex-wrap: wrap; gap: .65rem; justify-content: center; margin-top: 1.25rem; } -.empty-state__actions:empty { display: none; } +.empty-state--mini h3 { + font-size: .92rem; +} + +.empty-state--mini p { + font-size: .78rem; + line-height: 1.45; + max-width: 300px; +} + +:host ::ng-deep .empty-state--mini .ant-empty-image { + margin-bottom: .45rem; +} + +@media (max-width: 560px) { + .empty-state { + border-radius: 16px; + min-height: 300px; + padding: 1.35rem 1rem; + } + + .empty-state__art { + max-width: 190px; + width: min(58vw, 190px); + } + + .empty-state--compact { + min-height: 245px; + } + + .empty-state--compact .empty-state__art { + max-width: 135px; + } + + .empty-state--mini { + min-height: 150px; + padding: .75rem .5rem; + } + + .empty-state__actions { + align-items: stretch; + flex-direction: column; + width: 100%; + } + + .empty-state__actions > * { + justify-content: center; + width: 100%; + } +} diff --git a/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.spec.ts b/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.spec.ts new file mode 100644 index 00000000..54568939 --- /dev/null +++ b/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.spec.ts @@ -0,0 +1,47 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { NzButtonModule } from 'ng-zorro-antd/button'; +import { EstadoVacio } from './estado-vacio'; + +@Component({ + imports: [EstadoVacio, NzButtonModule], + template: ` + + + + `, +}) +class HostEstadoVacio {} + +describe('EstadoVacio', () => { + beforeEach(() => TestBed.configureTestingModule({ + imports: [EstadoVacio, HostEstadoVacio], + })); + + it('usa el robot canónico dentro de nz-empty y conserva el mensaje contextual', () => { + const fixture = TestBed.createComponent(HostEstadoVacio); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + const image = element.querySelector('.empty-state__art img'); + + expect(element.querySelector('nz-empty')).not.toBeNull(); + expect(image?.getAttribute('src')).toBe('/img/empty/empty-robot.webp'); + expect(image?.getAttribute('alt')).toBe(''); + expect(element.querySelector('h3')?.textContent).toContain('Sin cursos'); + expect(element.querySelector('p')?.textContent).toContain('Tu docente todavía no asignó una ruta.'); + expect(element.querySelector('.empty-state')?.classList).toContain('empty-state--compact'); + expect(element.querySelector('.ant-btn-primary')?.textContent).toContain('Actualizar'); + }); + + it('evita una imagen rota y presenta un fallback al fallar el asset', () => { + const fixture = TestBed.createComponent(EstadoVacio); + fixture.detectChanges(); + const image = fixture.nativeElement.querySelector('.empty-state__art img') as HTMLImageElement; + + image.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.empty-state__art img')).toBeNull(); + expect(fixture.nativeElement.querySelector('.empty-state__fallback svg')).not.toBeNull(); + }); +}); diff --git a/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.ts b/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.ts index 1bc87872..72542c14 100644 --- a/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.ts +++ b/frontend-angular/src/app/shared/componentes/estado-vacio/estado-vacio.ts @@ -1,13 +1,38 @@ -import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, signal } from '@angular/core'; +import { NzEmptyModule } from 'ng-zorro-antd/empty'; + +export type TamanoEstadoVacio = 'default' | 'compact' | 'mini'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-estado-vacio', + imports: [NzEmptyModule], templateUrl: './estado-vacio.html', styleUrl: './estado-vacio.scss', }) export class EstadoVacio { + private readonly imagenPredeterminada = '/img/empty/empty-robot.webp'; + private imagenActual = this.imagenPredeterminada; + @Input() titulo = 'Sin información'; @Input() descripcion = 'Todavía no hay registros para mostrar.'; @Input() compacto = false; + @Input() tamano: TamanoEstadoVacio = 'default'; + @Input() anunciar = true; + + readonly imagenDisponible = signal(true); + + @Input() + set imagen(valor: string | null | undefined) { + this.imagenActual = valor?.trim() || this.imagenPredeterminada; + this.imagenDisponible.set(true); + } + + get imagen(): string { + return this.imagenActual; + } + + registrarImagenFallida(): void { + this.imagenDisponible.set(false); + } } diff --git a/frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.html b/frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.html new file mode 100644 index 00000000..bab7566d --- /dev/null +++ b/frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.html @@ -0,0 +1,30 @@ +
+ @if (hasImage()) { + + } @else { + + } +
diff --git a/frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.scss b/frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.scss new file mode 100644 index 00000000..e79ac8c3 --- /dev/null +++ b/frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.scss @@ -0,0 +1,69 @@ +:host { + display: block; + min-width: 0; + width: 100%; +} + +.illustration-slot { + align-items: center; + aspect-ratio: var(--illustration-aspect-ratio, 4 / 3); + display: flex; + justify-content: center; + margin: 0; + min-height: 0; + overflow: hidden; + position: relative; + width: 100%; +} + +.illustration-slot img { + height: 100%; + object-fit: contain; + object-position: center; + width: 100%; +} + +.illustration-slot__fallback { + align-items: center; + background: #f3f5ff; + border: 1px dashed #c9d2ea; + border-radius: 14px; + color: #63708a; + display: flex; + flex-direction: column; + font-size: .72rem; + font-weight: 700; + gap: .45rem; + height: 100%; + justify-content: center; + letter-spacing: .01em; + min-height: 96px; + padding: 1rem; + text-align: center; + width: 100%; +} + +.illustration-slot__fallback svg { + height: clamp(2rem, 20%, 3.25rem); + width: clamp(2rem, 20%, 3.25rem); +} + +.illustration-slot[data-kind='hero'] .illustration-slot__fallback, +.illustration-slot[data-kind='decorative'] .illustration-slot__fallback { + background: #f5f3ff; + border-color: #d9d2f3; + color: #6750a4; +} + +.illustration-slot[data-kind='empty-state'] .illustration-slot__fallback { + background: #f7f9ff; + border-color: #dbe3f2; + color: #526174; +} + +@media (prefers-reduced-motion: reduce) { + .illustration-slot img { + animation: none; + transition: none; + } +} diff --git a/frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.spec.ts b/frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.spec.ts new file mode 100644 index 00000000..197b8a59 --- /dev/null +++ b/frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { IllustrationSlot } from './illustration-slot'; + +describe('IllustrationSlot', () => { + it('preserva la región visual y muestra fallback cuando falla el asset', async () => { + await TestBed.configureTestingModule({ imports: [IllustrationSlot] }).compileComponents(); + const fixture = TestBed.createComponent(IllustrationSlot); + fixture.componentRef.setInput('src', '/img/no-existe.webp'); + fixture.componentRef.setInput('alt', 'Guía del catálogo'); + fixture.componentRef.setInput('assetName', 'course-catalog-guide.webp'); + fixture.detectChanges(); + + const image = (fixture.nativeElement as HTMLElement).querySelector('img'); + image?.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + const figure = (fixture.nativeElement as HTMLElement).querySelector('figure'); + expect(figure?.getAttribute('data-asset-name')).toBe('course-catalog-guide.webp'); + expect(figure?.getAttribute('aria-label')).toBe('Guía del catálogo'); + expect(figure?.querySelector('img')).toBeNull(); + expect(figure?.querySelector('.illustration-slot__fallback')).not.toBeNull(); + }); + + it('prioriza el asset cuando el slot se encuentra sobre el pliegue', async () => { + await TestBed.configureTestingModule({ imports: [IllustrationSlot] }).compileComponents(); + const fixture = TestBed.createComponent(IllustrationSlot); + fixture.componentRef.setInput('src', '/img/hero-monster.png'); + fixture.componentRef.setInput('eager', true); + fixture.detectChanges(); + + const image = (fixture.nativeElement as HTMLElement).querySelector('img'); + expect(image?.getAttribute('loading')).toBe('eager'); + expect(image?.getAttribute('fetchpriority')).toBe('high'); + }); +}); diff --git a/frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.ts b/frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.ts new file mode 100644 index 00000000..006fd804 --- /dev/null +++ b/frontend-angular/src/app/shared/componentes/illustration-slot/illustration-slot.ts @@ -0,0 +1,33 @@ +import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core'; + +export type IllustrationKind = + | 'decorative' + | 'contextual' + | 'instructional' + | 'reward' + | 'empty-state' + | 'hero' + | 'interactive'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'daemon-illustration-slot', + templateUrl: './illustration-slot.html', + styleUrl: './illustration-slot.scss', +}) +export class IllustrationSlot { + readonly src = input(null); + readonly alt = input(''); + readonly kind = input('contextual'); + readonly aspectRatio = input('4 / 3'); + readonly assetName = input(''); + readonly eager = input(false); + + readonly failed = signal(false); + readonly hasImage = computed(() => Boolean(this.src()) && !this.failed()); + readonly fallbackLabel = computed(() => this.alt().trim() || 'Ilustración pendiente'); + + onImageError(): void { + this.failed.set(true); + } +} diff --git a/frontend-angular/src/environments/environment.development.ts b/frontend-angular/src/environments/environment.development.ts index 15ab83e7..673feb23 100644 --- a/frontend-angular/src/environments/environment.development.ts +++ b/frontend-angular/src/environments/environment.development.ts @@ -1,5 +1,8 @@ export const environment = { production: false, + // Mantener el mismo hostname que usa Angular en desarrollo. Mezclar + // localhost con 127.0.0.1 convierte la cookie HttpOnly en cross-site y el + // navegador descarta la sesión justo después de un login exitoso. apiUrl: 'http://localhost:8000/api/v1', assetBaseUrl: '', observability: { diff --git a/frontend-angular/src/styles.scss b/frontend-angular/src/styles.scss index bba932cd..674ed867 100644 --- a/frontend-angular/src/styles.scss +++ b/frontend-angular/src/styles.scss @@ -42,20 +42,20 @@ @layer utilities { /* Efecto Premium magnético para tarjetas interactuables */ .hover-premium { - @apply transition-all duration-300 shadow-premium hover:shadow-premium-hover hover:-translate-y-1; + @apply shadow-sm transition-shadow hover:shadow-md motion-reduce:transition-none; } /* Contenedores fijos premium */ .card-premium { - @apply rounded-4xl border-0 bg-white shadow-bento; + @apply rounded-2xl border border-slate-200 bg-white shadow-sm; } .daemon-glass { - @apply border border-white/70 bg-white/80 shadow-glass backdrop-blur-xl; + @apply border border-slate-200 bg-white shadow-sm; } .daemon-bento { - @apply rounded-4xl border-0 bg-white shadow-bento transition-all duration-300 hover:-translate-y-1 hover:shadow-bento-hover; + @apply rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow hover:shadow-md motion-reduce:transition-none; } .text-gradient-daemon {