From 2c971a0902e2abfa1f2294f28337b46163b46378 Mon Sep 17 00:00:00 2001 From: William Medina Date: Sat, 18 Jul 2026 21:07:29 -0500 Subject: [PATCH 01/13] =?UTF-8?q?style(alumno):=20redise=C3=B1o=20de=20mis?= =?UTF-8?q?-desafios=20e=20integraci=C3=B3n=20de=20nz-empty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/mis-desafios/mis-desafios.html | 57 +++++++++++-------- .../alumno/pages/mis-desafios/mis-desafios.ts | 7 ++- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/frontend-angular/src/app/features/alumno/pages/mis-desafios/mis-desafios.html b/frontend-angular/src/app/features/alumno/pages/mis-desafios/mis-desafios.html index e58d4d30..ddae0815 100644 --- a/frontend-angular/src/app/features/alumno/pages/mis-desafios/mis-desafios.html +++ b/frontend-angular/src/app/features/alumno/pages/mis-desafios/mis-desafios.html @@ -1,58 +1,65 @@
-
+
-

Mis desafíos

-

Misiones disponibles

-

Retos activos para tu nivel y estado de tus entregas.

+ MIS DESAFÍOS +

Misiones disponibles

+

Retos activos para tu nivel y estado de tus entregas.

-
@if (cargando()) { - + } @else if (error()) { -
+

{{ error() }}

} @else if (misiones().length) { -
+
@for (mision of misiones(); track mision.id) { -
+
-
- +
+ {{ (mision.entrega?.estado || mision.estado) | uppercase }}
-

{{ mision.titulo }}

-

{{ mision.descripcion || 'Sin descripción registrada.' }}

+

{{ mision.titulo }}

+

{{ mision.descripcion || 'Sin descripción registrada.' }}

-
+
- Recompensa - {{ mision.recompensa }} DAEMONS + Recompensa + {{ mision.recompensa }} DAEMONS
- Nivel Mínimo - {{ mision.nivel_requerido }} + Nivel Mínimo + {{ mision.nivel_requerido }}
-
}
} @else { - +
+ + + No hay misiones activas + Aún no tienes misiones disponibles para tu nivel. ¡Vuelve más tarde! + + +
}
diff --git a/frontend-angular/src/app/features/alumno/pages/mis-desafios/mis-desafios.ts b/frontend-angular/src/app/features/alumno/pages/mis-desafios/mis-desafios.ts index 02c4f5be..6dc56c43 100644 --- a/frontend-angular/src/app/features/alumno/pages/mis-desafios/mis-desafios.ts +++ b/frontend-angular/src/app/features/alumno/pages/mis-desafios/mis-desafios.ts @@ -1,15 +1,16 @@ -import { Component, signal , ChangeDetectionStrategy} from '@angular/core'; +import { Component, signal, ChangeDetectionStrategy } from '@angular/core'; import { RouterLink } from '@angular/router'; import { UpperCasePipe } from '@angular/common'; import { Mision } from '../../../misiones/services/mision'; import { Cargando } from '../../../../shared/componentes/cargando/cargando'; -import { EstadoVacio } from '../../../../shared/componentes/estado-vacio/estado-vacio'; import { NzTagModule } from 'ng-zorro-antd/tag'; +import { NzEmptyModule } from 'ng-zorro-antd/empty'; +import { NzButtonModule } from 'ng-zorro-antd/button'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-mis-desafios', - imports: [RouterLink, Cargando, UpperCasePipe, EstadoVacio, NzTagModule], + imports: [RouterLink, Cargando, UpperCasePipe, NzTagModule, NzEmptyModule, NzButtonModule], templateUrl: './mis-desafios.html', styleUrl: './mis-desafios.scss', }) From ca7d733246ae82bb7bc59c7368b1232a2b4145df Mon Sep 17 00:00:00 2001 From: William Medina Date: Sat, 18 Jul 2026 21:08:20 -0500 Subject: [PATCH 02/13] refactor(alumno): reescribir recursos con Tailwind CSS, eliminar SCSS obsoleto e integrar nz-empty --- .../alumno/pages/recursos/recursos.html | 119 +++++++++++------- .../alumno/pages/recursos/recursos.scss | 47 ------- .../alumno/pages/recursos/recursos.ts | 7 +- 3 files changed, 77 insertions(+), 96 deletions(-) delete mode 100644 frontend-angular/src/app/features/alumno/pages/recursos/recursos.scss diff --git a/frontend-angular/src/app/features/alumno/pages/recursos/recursos.html b/frontend-angular/src/app/features/alumno/pages/recursos/recursos.html index a610030f..82e93a4c 100644 --- a/frontend-angular/src/app/features/alumno/pages/recursos/recursos.html +++ b/frontend-angular/src/app/features/alumno/pages/recursos/recursos.html @@ -1,68 +1,87 @@ -
-
-
-

Campus de aprendizaje

-

Mis cursos

-

Continúa tus lecciones y revisa el avance de tu ruta académica.

+
+ +
+
+ CAMPUS DE APRENDIZAJE +

Mis cursos

+

Continúa tus lecciones y revisa el avance de tu ruta académica.

@if (datos(); as estado) { -
- {{ estado.resumen.porcentaje }}% - {{ estado.resumen.completadas }} de {{ estado.resumen.lecciones }} lecciones -
- +
+
+ {{ estado.resumen.porcentaje }}% + {{ estado.resumen.completadas }} de {{ estado.resumen.lecciones }} lecciones +
+
+
}
@if (error()) { - + +
} @if (cargando()) { -
- @for (item of [1, 2, 3]; track item) { -
- } -
+ } @else if (datos()?.cursos?.length) { -
+
@for (curso of datos()!.cursos; track curso.id) { -
-
-
- {{ curso.nivel || 'Todos los niveles' }} -

{{ curso.titulo }}

- @if (curso.descripcion) {

{{ curso.descripcion }}

} +
+ +
+
+
+ {{ curso.nivel || 'Todos los niveles' }} +

{{ curso.titulo }}

+ @if (curso.descripcion) {

{{ curso.descripcion }}

} +
+ {{ curso.unidades.length }} {{ curso.unidades.length === 1 ? 'unidad' : 'unidades' }}
- {{ curso.unidades.length }} {{ curso.unidades.length === 1 ? 'unidad' : 'unidades' }}
-
+
@for (unidad of curso.unidades; track unidad.id; let unitIndex = $index) { -
-
- {{ unitIndex + 1 }} -

{{ unidad.titulo }}

@if (unidad.descripcion) {

{{ unidad.descripcion }}

}
+
+ +
+ {{ unitIndex + 1 }} +
+

{{ unidad.titulo }}

+ @if (unidad.descripcion) {

{{ unidad.descripcion }}

} +
-
    + +
      @for (leccion of unidad.lecciones; track leccion.id) { -
    • - -
      - {{ leccion.titulo }} - {{ leccion.duracion_minutos ? leccion.duracion_minutos + ' min' : 'Lección' }} +
    • + +
      + +
      + {{ leccion.titulo }} + {{ leccion.duracion_minutos ? leccion.duracion_minutos + ' min' : 'Lección' }} +
      +
    • } @empty { -
    • Esta unidad todavía no tiene lecciones publicadas.
    • +
    • Esta unidad todavía no tiene lecciones publicadas.
    • }
@@ -81,9 +100,15 @@

{{ curso.titulo }}

}
} @else { -
- -
Tu ruta está preparándose

Cuando tu docente publique y asigne un curso, aparecerá aquí automáticamente.

-
+
+ + + Tu ruta está preparándose + Cuando tu docente publique y asigne un curso, aparecerá aquí automáticamente. + + +
}
diff --git a/frontend-angular/src/app/features/alumno/pages/recursos/recursos.scss b/frontend-angular/src/app/features/alumno/pages/recursos/recursos.scss deleted file mode 100644 index a7c16b98..00000000 --- a/frontend-angular/src/app/features/alumno/pages/recursos/recursos.scss +++ /dev/null @@ -1,47 +0,0 @@ -:host { display: block; } - -.learning-page { color: #172033; display: grid; gap: 1.25rem; margin: 0 auto; max-width: 1180px; padding: clamp(1rem, 2vw, 2rem); width: 100%; } -.learning-header { align-items: center; background: #fff; border: 1px solid #dfe5ee; border-radius: 20px; display: flex; gap: 1.5rem; justify-content: space-between; padding: clamp(1.25rem, 3vw, 2rem); } -.eyebrow { color: #315bea; font-size: .75rem; font-weight: 800; letter-spacing: .09em; margin: 0 0 .35rem; text-transform: uppercase; } -h1 { font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.035em; line-height: 1.1; margin: 0; } -.learning-header p:not(.eyebrow) { color: #60708a; margin: .55rem 0 0; } -.progress-card { background: #f5f8ff; border: 1px solid #d9e3ff; border-radius: 16px; display: grid; flex: 0 0 min(320px, 40%); gap: .35rem; padding: 1rem; } -.progress-card strong { color: #2348c6; font-size: 1.4rem; } -.progress-card span { color: #596980; font-size: .82rem; } -.progress-track { background: #dfe6f2; border-radius: 999px; height: 8px; overflow: hidden; } -.progress-track span { background: #315bea; display: block; height: 100%; transition: width .25s ease; } -.course-list { display: grid; gap: 1.25rem; } -.course-card { background: #fff; border: 1px solid #dfe5ee; border-radius: 20px; box-shadow: 0 8px 24px rgba(24, 40, 75, .06); overflow: hidden; } -.course-head { align-items: flex-start; border-bottom: 1px solid #edf0f5; display: flex; gap: 1rem; justify-content: space-between; padding: 1.35rem; } -.course-level { color: #315bea; font-size: .7rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; } -.course-head h2 { font-size: 1.25rem; margin: .25rem 0 0; } -.course-head p, .unit-card p { color: #66758c; font-size: .9rem; margin: .4rem 0 0; } -.course-count { background: #f1f4f9; border-radius: 999px; color: #53627a; flex: 0 0 auto; font-size: .75rem; font-weight: 700; padding: .45rem .7rem; } -.unit-list { display: grid; gap: 1rem; padding: 1.25rem; } -.unit-card { border: 1px solid #e3e8f0; border-radius: 16px; overflow: hidden; } -.unit-card > header { align-items: flex-start; background: #f8fafc; display: flex; gap: .8rem; padding: 1rem; } -.unit-number { align-items: center; background: #172d73; border-radius: 10px; color: #fff; display: inline-flex; flex: 0 0 32px; font-size: .8rem; font-weight: 800; height: 32px; justify-content: center; } -.unit-card h3 { font-size: 1rem; margin: .15rem 0 0; } -.lesson-list { display: grid; list-style: none; margin: 0; padding: 0; } -.lesson-list li { align-items: center; border-top: 1px solid #edf0f5; display: flex; gap: .75rem; min-height: 64px; padding: .75rem 1rem; } -.lesson-list li:first-child { border-top: 0; } -.lesson-status { align-items: center; background: #eef2f7; border-radius: 50%; color: #65748a; display: inline-flex; flex: 0 0 28px; font-weight: 900; height: 28px; justify-content: center; } -.lesson-copy { display: grid; flex: 1; gap: .15rem; min-width: 0; } -.lesson-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.lesson-copy span { color: #75839a; font-size: .75rem; } -.lesson-list button, .state-card button { background: #172d73; border: 0; border-radius: 10px; color: #fff; cursor: pointer; font: inherit; font-size: .78rem; font-weight: 800; min-height: 38px; padding: .55rem .8rem; } -.lesson-list button:disabled { background: #e7ebf2; color: #64748b; cursor: default; } -.lesson-completed .lesson-status { background: #dcfce7; color: #15733b; } -.lesson-empty { color: #718096; font-size: .85rem; justify-content: center; text-align: center; } -.state-card { align-items: center; background: #fff; border: 1px solid #dfe5ee; border-radius: 18px; display: flex; gap: 1rem; justify-content: space-between; padding: 1.25rem; } -.state-card p { color: #66758c; margin: .3rem 0 0; } -.state-card--empty { justify-content: center; min-height: 180px; text-align: left; } -.state-card--empty > span { font-size: 2rem; } -.state-card--error { border-color: #fecaca; } -.course-grid { display: grid; gap: 1rem; grid-template-columns: repeat(3, minmax(0, 1fr)); } -.course-card--skeleton { display: grid; gap: .8rem; min-height: 180px; padding: 1.25rem; } -.course-card--skeleton span { animation: pulse 1.3s ease-in-out infinite; background: #edf1f6; border-radius: 8px; display: block; height: 20px; } -.course-card--skeleton span:nth-child(2) { width: 70%; }.course-card--skeleton span:nth-child(3) { height: 64px; } -@keyframes pulse { 50% { opacity: .45; } } -@media (max-width: 720px) { .learning-header { align-items: stretch; flex-direction: column; }.progress-card { flex-basis: auto; }.course-head { flex-direction: column; }.course-grid { grid-template-columns: 1fr; }.lesson-list li { align-items: flex-start; flex-wrap: wrap; }.lesson-list button { margin-left: 40px; width: calc(100% - 40px); } } -@media (prefers-reduced-motion: reduce) { .progress-track span, .course-card--skeleton span { animation: none; transition: none; } } diff --git a/frontend-angular/src/app/features/alumno/pages/recursos/recursos.ts b/frontend-angular/src/app/features/alumno/pages/recursos/recursos.ts index ca7714c1..75c5866c 100644 --- a/frontend-angular/src/app/features/alumno/pages/recursos/recursos.ts +++ b/frontend-angular/src/app/features/alumno/pages/recursos/recursos.ts @@ -1,6 +1,10 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { finalize } from 'rxjs'; import { Api } from '../../../../core/servicios/api'; +import { NzEmptyModule } from 'ng-zorro-antd/empty'; +import { NzButtonModule } from 'ng-zorro-antd/button'; +import { NzIconModule } from 'ng-zorro-antd/icon'; +import { Cargando } from '../../../../shared/componentes/cargando/cargando'; interface ProgresoLeccion { estado: 'notStarted' | 'inProgress' | 'completed'; @@ -38,9 +42,8 @@ interface AprendizajeResponse { @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'app-recursos', - imports: [], + imports: [NzEmptyModule, NzButtonModule, NzIconModule, Cargando], templateUrl: './recursos.html', - styleUrl: './recursos.scss', }) export class Recursos { private readonly api = inject(Api); From e55cd726085bdda6f604ad1900641f0c10ee84ce Mon Sep 17 00:00:00 2001 From: William Medina Date: Sat, 18 Jul 2026 21:09:40 -0500 Subject: [PATCH 03/13] =?UTF-8?q?refactor(alumno):=20modernizar=20el=20per?= =?UTF-8?q?fil=20del=20alumno=20con=20Tailwind=20CSS=20e=20integrarlo=20al?= =?UTF-8?q?=20dise=C3=B1o=20unificado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/perfil-alumno/perfil-alumno.html | 253 +++++++++++------- .../pages/perfil-alumno/perfil-alumno.scss | 144 ---------- .../pages/perfil-alumno/perfil-alumno.ts | 7 +- 3 files changed, 164 insertions(+), 240 deletions(-) delete mode 100644 frontend-angular/src/app/features/alumno/pages/perfil-alumno/perfil-alumno.scss diff --git a/frontend-angular/src/app/features/alumno/pages/perfil-alumno/perfil-alumno.html b/frontend-angular/src/app/features/alumno/pages/perfil-alumno/perfil-alumno.html index fe872712..7b180e94 100644 --- a/frontend-angular/src/app/features/alumno/pages/perfil-alumno/perfil-alumno.html +++ b/frontend-angular/src/app/features/alumno/pages/perfil-alumno/perfil-alumno.html @@ -1,137 +1,204 @@ @if (cargando()) { - + } @else if (error()) { -
- - +
+
+ + +
} @else if (perfil(); as datos) { -
-
-
+
+ + +
+ +
+ +
-
- -

{{ datos.usuario.nombre_completo || 'Usuario nuevo' }}

-

@{{ datos.usuario.usuario }}

-
- {{ datos.usuario.nivel || 'Nivel inicial' }} - {{ datos.usuario.rango || 'Rango en progreso' }} +
+ {{ perfilPropio() ? 'Mi perfil' : 'Perfil de compañero' }} +

{{ datos.usuario.nombre_completo || 'Usuario nuevo' }}

+

@{{ datos.usuario.usuario }}

+
+ {{ datos.usuario.nivel || 'Nivel inicial' }} + {{ datos.usuario.rango || 'Rango en progreso' }}
-
-
- Perfil completo - {{ progresoPerfil(datos.usuario) }}% +
+
+ Perfil completo + {{ progresoPerfil(datos.usuario) }}%
- @if (perfilPropio()) { - Editar perfil - } @else { - Volver a comunidad - } +
+ @if (perfilPropio()) { + Editar perfil + } @else { + Volver a comunidad + } +
-
-
- Experiencia total - {{ datos.usuario.experiencia || 0 }} XP -

Nivel {{ datos.usuario.nivel_gamificacion || 1 }}

+ +
+
+ Experiencia total + {{ datos.usuario.experiencia || 0 }} XP +

Nivel {{ datos.usuario.nivel_gamificacion || 1 }}

-
- Saldo DAEMONS - -

Disponible en la tienda

+
+ Saldo DAEMONS + +

Disponible en la tienda

-
- Insignias ganadas - {{ datos.insignias.length }} -

Logros reconocidos

+
+ Insignias ganadas + {{ datos.insignias.length }} +

Logros reconocidos

-
- Premios en mochila - {{ datos.mochila.length }} -

Recompensas entregadas

+
+ Premios en mochila + {{ datos.mochila.length }} +

Recompensas entregadas

-
-
-
-
Cuenta

Datos personales

+ +
+ + +
+
+ Cuenta +

Datos personales

-
-
- -

Correo electrónico{{ datos.usuario.email || 'Oculto por privacidad' }}

+
+
+
+ +
+
+ Correo electrónico + {{ datos.usuario.email || 'Oculto por privacidad' }} +
-
- -

Biografía{{ datos.usuario.biografia || 'El estudiante aún no ha escrito su biografía.' }}

+
+
+ +
+
+ Biografía + {{ datos.usuario.biografia || 'El estudiante aún no ha escrito su biografía.' }} +
-
-
-
Recompensas

Mochila digital

- {{ datos.mochila.length }} premios + +
+
+
+ Recompensas +

Mochila digital

+
+ {{ datos.mochila.length }} premios
- @if (datos.mochila.length) { -
- @for (premio of datos.mochila; track premio.id) { -
- @if (premio.imagen) { - + +
+ @if (datos.mochila.length) { +
+ @for (premio of datos.mochila; track premio.id) { +
+ @if (premio.imagen) { + + } @else { +
+ +
+ } +
+ {{ premio.nombre }} + {{ premio.descripcion || 'Premio entregado' }} +
+
+ } +
+ } @else { +
+ + + {{ perfilPropio() ? 'Mochila sin premios' : 'Mochila vacía' }} + {{ perfilPropio() ? 'Todavía no tienes premios entregados.' : 'Todavía no tiene premios entregados.' }} + + +
+ } +
+
+
+ + +
+
+
+ Progreso +

Insignias ganadas

+
+ {{ datos.insignias.length }} insignias +
+ +
+ @if (datos.insignias.length) { +
+ @for (insignia of datos.insignias; track insignia.id) { +
+ @if (insignia.imagen) { + } @else { - +
+ +
} -
{{ premio.nombre }}{{ premio.descripcion || 'Premio entregado' }}
+
+ {{ insignia.nombre }} + {{ insignia.descripcion }} + {{ insignia.fecha }} +
}
} @else { - +
+ + + Aún sin insignias + Cuando completes logros importantes aparecerán aquí. + + +
} -
-
- -
-
-
Progreso

Insignias ganadas

- {{ datos.insignias.length }} insignias -
- @if (datos.insignias.length) { -
- @for (insignia of datos.insignias; track insignia.id) { -
- @if (insignia.imagen) { - - } -
{{ insignia.nombre }}{{ insignia.descripcion }}{{ insignia.fecha }}
-
- } -
- } @else { - - } +
+ } diff --git a/frontend-angular/src/app/features/alumno/pages/perfil-alumno/perfil-alumno.scss b/frontend-angular/src/app/features/alumno/pages/perfil-alumno/perfil-alumno.scss deleted file mode 100644 index 0027002d..00000000 --- a/frontend-angular/src/app/features/alumno/pages/perfil-alumno/perfil-alumno.scss +++ /dev/null @@ -1,144 +0,0 @@ -:host { display: block; } - -.profile-page { - display: grid; - gap: 1.25rem; - margin: 0 auto; - max-width: 1280px; -} - -.profile-hero { - align-items: center; - background: #eaf5ff; - border: 1px solid #d7eaff; - border-radius: 16px; - display: grid; - gap: 2rem; - grid-template-columns: minmax(0, 1fr) 270px; - min-height: 210px; - padding: clamp(1.35rem, 3vw, 2.25rem); -} - -.profile-identity { - align-items: center; - display: flex; - gap: 1.35rem; - min-width: 0; -} - -:host ::ng-deep .profile-avatar.ant-avatar { - background: #1677ff; - border: 5px solid #fff; - box-shadow: 0 8px 22px rgba(40, 80, 130, .13); - color: #fff; - flex: 0 0 auto; - font-size: 1.7rem; - font-weight: 700; -} - -.identity-copy { min-width: 0; } -.section-label, -.card-title span, -.stat-card > span { - color: #667085; - font-size: .67rem; - font-weight: 700; - letter-spacing: .075em; - text-transform: uppercase; -} -.identity-copy h1 { color: #172033; font-size: clamp(1.7rem, 3vw, 2.35rem); font-weight: 700; line-height: 1.1; margin: .4rem 0 .3rem; } -.identity-copy > p { color: #667085; margin: 0; } -.identity-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: .85rem; } -.solid-tag { border-radius: 6px; color: #fff; font-size: .68rem; font-weight: 700; letter-spacing: .04em; padding: .35rem .6rem; text-transform: uppercase; } -.solid-tag--blue { background: #1677ff; } -.solid-tag--green { background: #12a150; } - -.profile-completion { - background: #fff; - border: 1px solid #dce8f5; - border-radius: 12px; - display: grid; - gap: .75rem; - padding: 1rem; -} -.profile-completion > div { align-items: center; display: flex; justify-content: space-between; } -.profile-completion span { color: #667085; font-size: .78rem; font-weight: 600; } -.profile-completion strong { color: #172033; font-size: .9rem; } -:host ::ng-deep .edit-profile-button.ant-btn { border-radius: 8px; min-height: 40px; width: 100%; } - -.profile-stats { - display: grid; - gap: 1rem; - grid-template-columns: repeat(4, minmax(0, 1fr)); -} - -.stat-card { - border: 1px solid; - border-radius: 14px; - display: grid; - min-height: 128px; - padding: 1rem 1.1rem; -} -.stat-card strong { color: #172033; font-size: 1.65rem; font-weight: 700; line-height: 1.1; margin-top: .75rem; } -.stat-card strong small { font-size: .7rem; letter-spacing: .04em; } -.stat-card p { color: #667085; font-size: .72rem; margin: .35rem 0 0; } -.stat-card--blue { background: #edf6ff; border-color: #d4e9ff; } -.stat-card--amber { background: #fffbeb; border-color: #f5e3a7; } -.stat-card--green { background: #edf9f1; border-color: #d4eedc; } -.stat-card--violet { background: #f5f1ff; border-color: #e6ddff; } - -.surface-card { - background: #fff; - border: 1px solid #e4eaf2; - border-radius: 16px; - padding: 1.25rem; -} -.profile-content { display: grid; gap: 1.25rem; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); } -.card-title { align-items: end; border-bottom: 1px solid #edf1f5; display: flex; justify-content: space-between; margin-bottom: 1rem; padding-bottom: .9rem; } -.card-title h2 { color: #172033; font-size: 1.05rem; margin: .2rem 0 0; } -.card-title > small { background: #f3f6fa; border-radius: 7px; color: #667085; font-size: .68rem; font-weight: 600; padding: .35rem .5rem; } - -.information-list { display: grid; gap: .75rem; } -.information-list > div { align-items: flex-start; background: #f8fafc; border: 1px solid #edf1f5; border-radius: 10px; display: grid; gap: .75rem; grid-template-columns: 36px minmax(0, 1fr); padding: .85rem; } -.information-list > div > span { align-items: center; background: #edf5ff; border-radius: 8px; color: #1677ff; display: flex; height: 36px; justify-content: center; } -.information-list p { display: grid; gap: .25rem; margin: 0; min-width: 0; } -.information-list small { color: #7a8699; font-size: .67rem; font-weight: 700; letter-spacing: .055em; text-transform: uppercase; } -.information-list strong { color: #344054; font-size: .84rem; font-weight: 600; overflow-wrap: anywhere; } - -.reward-list, -.badge-list { display: grid; gap: .75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); } -.reward-list > article, -.badge-list > article { align-items: center; background: #f8fafc; border: 1px solid #edf1f5; border-radius: 10px; display: flex; gap: .8rem; min-width: 0; padding: .75rem; } -.reward-list img, -.badge-list img, -.reward-placeholder { background: #fff; border: 1px solid #e4eaf2; border-radius: 9px; height: 48px; object-fit: contain; width: 48px; } -.reward-placeholder { align-items: center; color: #98a2b3; display: flex; justify-content: center; } -.reward-list div, -.badge-list div { display: grid; min-width: 0; } -.reward-list strong, -.badge-list strong { color: #344054; font-size: .82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.reward-list small, -.badge-list span, -.badge-list small { color: #7a8699; font-size: .7rem; margin-top: .2rem; } -.badge-list { grid-template-columns: repeat(3, minmax(0, 1fr)); } -.badges-card { min-height: 180px; } - -.profile-state { background: #fff; border: 1px solid #e4eaf2; border-radius: 16px; display: grid; gap: 1rem; padding: 2rem; } - -@media (max-width: 1000px) { - .profile-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .profile-content { grid-template-columns: 1fr; } - .badge-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } -} - -@media (max-width: 720px) { - .profile-hero { grid-template-columns: 1fr; } - .profile-completion { width: 100%; } -} - -@media (max-width: 560px) { - .profile-identity { align-items: flex-start; flex-direction: column; } - .profile-stats, - .reward-list, - .badge-list { grid-template-columns: 1fr; } -} diff --git a/frontend-angular/src/app/features/alumno/pages/perfil-alumno/perfil-alumno.ts b/frontend-angular/src/app/features/alumno/pages/perfil-alumno/perfil-alumno.ts index 16599c8a..1c9af93f 100644 --- a/frontend-angular/src/app/features/alumno/pages/perfil-alumno/perfil-alumno.ts +++ b/frontend-angular/src/app/features/alumno/pages/perfil-alumno/perfil-alumno.ts @@ -8,10 +8,11 @@ import { NzDescriptionsModule } from 'ng-zorro-antd/descriptions'; import { NzProgressModule } from 'ng-zorro-antd/progress'; import { NzStatisticModule } from 'ng-zorro-antd/statistic'; import { NzTagModule } from 'ng-zorro-antd/tag'; +import { NzEmptyModule } from 'ng-zorro-antd/empty'; +import { NzIconModule } from 'ng-zorro-antd/icon'; import { Activos } from '../../../../core/servicios/activos'; import { Sesion } from '../../../../core/servicios/sesion'; import { Cargando } from '../../../../shared/componentes/cargando/cargando'; -import { EstadoVacio } from '../../../../shared/componentes/estado-vacio/estado-vacio'; import { MonedaDaemon } from '../../../../shared/componentes/moneda-daemon/moneda-daemon'; import { Alumno } from '../../services/alumno'; @@ -48,12 +49,12 @@ interface PerfilData { NzProgressModule, NzStatisticModule, NzTagModule, + NzEmptyModule, + NzIconModule, Cargando, - EstadoVacio, MonedaDaemon, ], templateUrl: './perfil-alumno.html', - styleUrl: './perfil-alumno.scss', }) export class PerfilAlumno { perfil = signal(null); From 3447227d30547bb607821badf18676d20ef849ee Mon Sep 17 00:00:00 2001 From: William Medina Date: Sat, 18 Jul 2026 22:08:59 -0500 Subject: [PATCH 04/13] fix(frontend): integrar cambios de estudiante con QA --- AGENTS.md | 8 + ANTIGRAVITY.md | 71 ++++ docs/colaboracion-ias.md | 75 +++++ docs/handoffs/antigravity-to-codex.md | 19 ++ docs/handoffs/codex-to-antigravity.md | 55 ++++ frontend-angular/package.json | 3 +- .../scripts/check-student-visual.mjs | 64 ++++ .../layouts/layout-alumno/layout-alumno.html | 6 - .../layouts/layout-alumno/layout-alumno.ts | 83 +---- .../layout-docente/layout-docente.html | 6 - .../layouts/layout-docente/layout-docente.ts | 12 - .../sidebar-portal/sidebar-portal.html | 109 ------ .../sidebar-portal/sidebar-portal.scss | 310 +----------------- .../layouts/sidebar-portal/sidebar-portal.ts | 112 ------- .../layouts/topbar-alumno/topbar-alumno.html | 3 +- .../src/app/core/servicios/tour.service.ts | 8 +- .../pages/mis-desafios/mis-desafios.html | 26 +- .../pages/panel-alumno/panel-alumno.html | 37 +-- .../pages/perfil-alumno/perfil-alumno.html | 64 ++-- .../pages/perfil-alumno/perfil-alumno.ts | 26 +- .../alumno/pages/recursos/recursos.html | 43 ++- .../alumno/pages/recursos/recursos.ts | 4 +- .../environments/environment.development.ts | 2 +- frontend-angular/src/styles.scss | 8 +- 24 files changed, 422 insertions(+), 732 deletions(-) create mode 100644 ANTIGRAVITY.md create mode 100644 docs/colaboracion-ias.md create mode 100644 docs/handoffs/antigravity-to-codex.md create mode 100644 docs/handoffs/codex-to-antigravity.md create mode 100644 frontend-angular/scripts/check-student-visual.mjs 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/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/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/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/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); } } From e4c6c9f5d808d3c6c658b66742a02b5eb6b037db Mon Sep 17 00:00:00 2001 From: William Medina Date: Sun, 19 Jul 2026 13:14:45 -0500 Subject: [PATCH 12/13] style(student): simplify course visual hierarchy --- docs/piloto-visual-cursos-2026-07-19.md | 25 +++- .../alumno/pages/recursos/recursos.html | 3 +- .../alumno/pages/recursos/recursos.scss | 120 +++++++++--------- 3 files changed, 85 insertions(+), 63 deletions(-) diff --git a/docs/piloto-visual-cursos-2026-07-19.md b/docs/piloto-visual-cursos-2026-07-19.md index bdb53287..8993b56b 100644 --- a/docs/piloto-visual-cursos-2026-07-19.md +++ b/docs/piloto-visual-cursos-2026-07-19.md @@ -359,8 +359,8 @@ Resultado del piloto antes de publicar: - suite backend completa: 134 casos y 475 aserciones aprobados; - arquitectura `shared/core/features`: aprobada; - build de producción: aprobado; -- bundle inicial: 908.25 kB, bajo el presupuesto de advertencia de 1 MB; -- chunk lazy de Cursos: 51.42 kB sin comprimir, 10.82 kB estimado transferido; +- 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 @@ -459,3 +459,24 @@ cambios de variables de entorno ni reversión de datos. | 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/frontend-angular/src/app/features/alumno/pages/recursos/recursos.html b/frontend-angular/src/app/features/alumno/pages/recursos/recursos.html index 3f9cbbda..bd4a8655 100644 --- a/frontend-angular/src/app/features/alumno/pages/recursos/recursos.html +++ b/frontend-angular/src/app/features/alumno/pages/recursos/recursos.html @@ -110,7 +110,6 @@

{{ problemaCarga().titulo }}

> {{ opcion.label }} - {{ opcion.count }} } @@ -120,7 +119,7 @@

{{ problemaCarga().titulo }}

fa-icon { - font-size: .72rem; + color: #38506f; + font-size: .78rem; } -.course-filter strong { - align-items: center; - background: #eef2f7; - border-radius: 999px; - color: #596579; - display: inline-flex; - font-size: .65rem; - height: 22px; - justify-content: center; - min-width: 22px; - padding: 0 .35rem; +.course-filter:hover { + background: transparent; + border-color: transparent; + color: var(--course-filter-accent); } -.course-filter:hover { - background: #f5f8fc; - border-color: #dce3ed; - color: var(--course-ink); +.course-filter:hover > fa-icon { + color: var(--course-filter-accent); } .course-filter--active { - background: var(--course-accent-dark); - border-color: var(--course-accent-dark); + background: var(--course-filter-accent); + border-color: var(--course-filter-accent); color: #fff; } .course-filter--active:hover { - background: #074f9f; - border-color: #074f9f; + background: #5e2bc1; + border-color: #5e2bc1; color: #fff; } -.course-filter--active strong { - background: rgba(255, 255, 255, .2); +.course-filter--active > fa-icon, +.course-filter--active:hover > fa-icon { color: #fff; } @@ -811,14 +811,15 @@ .course-summary-card > header > span, .course-recommendation-card > span { align-items: center; - background: #f0edff; - border-radius: 10px; - color: #6842d7; + background: transparent; + border-radius: 0; + color: #6f36dc; display: inline-flex; flex: 0 0 auto; + font-size: 1.15rem; height: 38px; justify-content: center; - width: 38px; + width: 28px; } .course-aside h2 { @@ -899,13 +900,14 @@ .course-help-card__header > span { align-items: center; - background: #f0edff; - border-radius: 9px; - color: #6842d7; + background: transparent; + border-radius: 0; + color: #6f36dc; display: inline-flex; + font-size: 1rem; height: 32px; justify-content: center; - width: 32px; + width: 24px; } .course-help-card ol { @@ -925,15 +927,15 @@ .course-help-card li > span { align-items: center; - background: #edf5ff; - border-radius: 8px; - color: var(--course-accent-dark); + background: transparent; + border-radius: 0; + color: #6842d7; display: inline-flex; - font-size: .68rem; + font-size: .92rem; font-weight: 800; height: 28px; justify-content: center; - width: 28px; + width: 22px; } .course-help-card li p { @@ -951,8 +953,8 @@ } .course-recommendation-card > span { - background: #fff7e6; - color: #b86e00; + background: transparent; + color: #d68400; } .course-recommendation-card a { @@ -1079,7 +1081,7 @@ input:focus-visible { * responden al ancho real del catálogo y evitan depender de nombres de * dispositivo o de duplicar el DOM para una variante compacta. */ -@container course-catalog (max-width: 760px) { +@container course-catalog (max-width: 980px) { .course-hero { grid-template-columns: minmax(0, 1fr) minmax(180px, 240px); } From 95245381b6231bb9f779710260c2f00eedcfc6e5 Mon Sep 17 00:00:00 2001 From: William Medina Date: Sun, 19 Jul 2026 14:36:08 -0500 Subject: [PATCH 13/13] fix(student): preserve cached courses and route feedback --- docs/infraestructura-operativa.md | 15 +++++++-- docs/piloto-visual-cursos-2026-07-19.md | 5 +-- .../app/core/servicios/navegacion-app.spec.ts | 29 +++++++++++++++-- .../src/app/core/servicios/navegacion-app.ts | 31 ++++++++++++++++++- .../alumno/pages/recursos/recursos.spec.ts | 19 +++++++++++- .../alumno/pages/recursos/recursos.ts | 5 ++- 6 files changed, 95 insertions(+), 9 deletions(-) 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 index 8993b56b..89e88722 100644 --- a/docs/piloto-visual-cursos-2026-07-19.md +++ b/docs/piloto-visual-cursos-2026-07-19.md @@ -330,8 +330,9 @@ aunque el viewport por sí solo todavía sea ancho. - 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 puede usar la - respuesta cacheada mientras se revalida según la política existente. +- 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. diff --git a/frontend-angular/src/app/core/servicios/navegacion-app.spec.ts b/frontend-angular/src/app/core/servicios/navegacion-app.spec.ts index ddaecbba..3bfbe910 100644 --- a/frontend-angular/src/app/core/servicios/navegacion-app.spec.ts +++ b/frontend-angular/src/app/core/servicios/navegacion-app.spec.ts @@ -8,6 +8,8 @@ describe('NavegacionApp', () => { let servicio: NavegacionApp; beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-19T00:00:00Z')); eventos = new Subject(); TestBed.configureTestingModule({ providers: [ @@ -19,7 +21,10 @@ describe('NavegacionApp', () => { servicio.iniciar(); }); - afterEach(() => servicio.detener()); + afterEach(() => { + servicio.detener(); + jest.useRealTimers(); + }); it('activa el indicador desde el primer evento de navegacion', () => { eventos.next(new NavigationStart(1, '/alumno/herramientas')); @@ -31,10 +36,30 @@ describe('NavegacionApp', () => { new NavigationEnd(1, '/alumno/herramientas', '/alumno/herramientas'), new NavigationCancel(2, '/alumno/perfil', 'cancelada'), new NavigationError(3, '/alumno/ranking', new Error('chunk')), - ])('oculta el indicador al finalizar o interrumpir la navegacion', (evento) => { + ])('mantiene visible y luego oculta el indicador al finalizar o interrumpir la navegacion', (evento) => { eventos.next(new NavigationStart(1, '/alumno/herramientas')); eventos.next(evento); + expect(servicio.activa()).toBe(true); + jest.advanceTimersByTime(239); + expect(servicio.activa()).toBe(true); + jest.advanceTimersByTime(1); + expect(servicio.activa()).toBe(false); + }); + + it('cancela el ocultado pendiente cuando comienza otra navegacion', () => { + eventos.next(new NavigationStart(1, '/alumno/herramientas')); + eventos.next(new NavigationEnd(1, '/alumno/herramientas', '/alumno/herramientas')); + jest.advanceTimersByTime(120); + + eventos.next(new NavigationStart(2, '/alumno/recursos')); + jest.advanceTimersByTime(120); + expect(servicio.activa()).toBe(true); + + eventos.next(new NavigationEnd(2, '/alumno/recursos', '/alumno/recursos')); + jest.advanceTimersByTime(119); + expect(servicio.activa()).toBe(true); + jest.advanceTimersByTime(1); expect(servicio.activa()).toBe(false); }); diff --git a/frontend-angular/src/app/core/servicios/navegacion-app.ts b/frontend-angular/src/app/core/servicios/navegacion-app.ts index 6316f549..82bcf2cc 100644 --- a/frontend-angular/src/app/core/servicios/navegacion-app.ts +++ b/frontend-angular/src/app/core/servicios/navegacion-app.ts @@ -4,7 +4,10 @@ import { Subscription } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class NavegacionApp { + private readonly duracionMinimaMs = 240; private readonly activaInterna = signal(false); + private inicioNavegacion = 0; + private ocultarTimer: ReturnType | null = null; private suscripcion: Subscription | null = null; readonly activa = this.activaInterna.asReadonly(); @@ -16,6 +19,8 @@ export class NavegacionApp { this.suscripcion = this.router.events.subscribe((evento) => { if (evento instanceof NavigationStart) { + this.cancelarOcultado(); + this.inicioNavegacion = Date.now(); this.activaInterna.set(true); return; } @@ -25,7 +30,7 @@ export class NavegacionApp { evento instanceof NavigationCancel || evento instanceof NavigationError ) { - this.activaInterna.set(false); + this.ocultarCuandoSeaVisible(); } }); } @@ -33,6 +38,30 @@ export class NavegacionApp { detener(): void { this.suscripcion?.unsubscribe(); this.suscripcion = null; + this.cancelarOcultado(); this.activaInterna.set(false); } + + private ocultarCuandoSeaVisible(): void { + const transcurrido = Date.now() - this.inicioNavegacion; + const restante = Math.max(0, this.duracionMinimaMs - transcurrido); + this.cancelarOcultado(); + + if (restante === 0) { + this.activaInterna.set(false); + return; + } + + this.ocultarTimer = setTimeout(() => { + this.ocultarTimer = null; + this.activaInterna.set(false); + }, restante); + } + + private cancelarOcultado(): void { + if (this.ocultarTimer === null) return; + + clearTimeout(this.ocultarTimer); + this.ocultarTimer = null; + } } diff --git a/frontend-angular/src/app/features/alumno/pages/recursos/recursos.spec.ts b/frontend-angular/src/app/features/alumno/pages/recursos/recursos.spec.ts index 3a88fa05..f3ec0ccb 100644 --- a/frontend-angular/src/app/features/alumno/pages/recursos/recursos.spec.ts +++ b/frontend-angular/src/app/features/alumno/pages/recursos/recursos.spec.ts @@ -1,7 +1,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; -import { of, throwError } from 'rxjs'; +import { concat, of, Subject, throwError } from 'rxjs'; import { Api, ApiError } from '../../../../core/servicios/api'; import { Recursos } from './recursos'; @@ -169,6 +169,23 @@ describe('Recursos', () => { expect(element.querySelector('.course-stale')?.textContent).toContain('Datos guardados'); }); + it('muestra la respuesta cacheada mientras la revalidación continúa en segundo plano', () => { + const actualizacion = new Subject(); + getMock.mockReturnValue(concat(of(respuesta), actualizacion)); + const fixture = TestBed.createComponent(Recursos); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('.course-loading')).toBeNull(); + expect(element.querySelectorAll('.course-card')).toHaveLength(3); + expect(fixture.componentInstance.refrescando()).toBe(true); + + actualizacion.next(respuesta); + actualizacion.complete(); + fixture.detectChanges(); + expect(fixture.componentInstance.refrescando()).toBe(false); + }); + it('actualiza el progreso local y conserva telemetría permitida al completar', () => { const fixture = TestBed.createComponent(Recursos); fixture.detectChanges(); diff --git a/frontend-angular/src/app/features/alumno/pages/recursos/recursos.ts b/frontend-angular/src/app/features/alumno/pages/recursos/recursos.ts index 6ed6978a..ba02475c 100644 --- a/frontend-angular/src/app/features/alumno/pages/recursos/recursos.ts +++ b/frontend-angular/src/app/features/alumno/pages/recursos/recursos.ts @@ -223,13 +223,16 @@ export class Recursos { })) .subscribe({ next: (datos) => { + const eraCargaInicial = this.cargando(); this.datos.set(datos); + this.cargando.set(false); + this.refrescando.set(eraCargaInicial); this.contenidoDesactualizado.set(false); this.tipoProblema.set('generic'); }, error: (problema: unknown) => { this.tipoProblema.set(this.clasificarProblema(problema)); - this.contenidoDesactualizado.set(conservaDatos); + this.contenidoDesactualizado.set(Boolean(this.datos())); this.error.set('No pudimos cargar tus cursos. Revisa tu conexión e inténtalo nuevamente.'); }, });