diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 87bdc67..4bbbc7f 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -92,11 +92,13 @@ def _register_blueprints(app: Flask) -> None: from app.controllers.panorama_bp import panorama_bp from app.controllers.alerts_bp import alerts_bp from app.controllers.admin_bp import admin_bp + from app.controllers.profile_bp import profile_bp app.register_blueprint(auth_bp, url_prefix="/api/auth") app.register_blueprint(panorama_bp, url_prefix="/api/panorama") app.register_blueprint(alerts_bp, url_prefix="/api/alerts") app.register_blueprint(admin_bp, url_prefix="/api/admin") + app.register_blueprint(profile_bp, url_prefix="/api/profile") def _register_schedulers(app: Flask) -> None: from scheduler.jobs import daily_pipeline diff --git a/backend/app/config.py b/backend/app/config.py index d4adfc7..0ed0bad 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -22,6 +22,7 @@ class BaseConfig: JWT_ERROR_MESSAGE_KEY = "error" # El JWT vive en una cookie httpOnly en vez de viajar en el cuerpo JSON, para que un script de XSS no pueda leerlo directamente. JWT_TOKEN_LOCATION = ["cookies"] + JWT_BLOCKLIST_TOKEN_CHECKS = ["access", "refresh"] JWT_COOKIE_SECURE = os.environ.get("JWT_COOKIE_SECURE", "false").lower() == "true" JWT_COOKIE_SAMESITE = "Lax" JWT_COOKIE_CSRF_PROTECT = True diff --git a/backend/app/controllers/profile_bp.py b/backend/app/controllers/profile_bp.py new file mode 100644 index 0000000..f6734a3 --- /dev/null +++ b/backend/app/controllers/profile_bp.py @@ -0,0 +1,131 @@ +import logging +from flask import Blueprint, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from marshmallow import ValidationError + +from app.repositories.skill_repository import SkillRepository +from app.repositories.user_repository import UserRepository +from app.repositories.user_skill_repository import UserSkillRepository +from app.services.profile_service import ProfileService +from app.schemas.profile_schema import ( + UpdateProfileSchema, + AddSkillSchema, + ChangePasswordSchema, +) +from app.utils.decorators import role_required +from app.utils.response import success_response, error_response + +logger = logging.getLogger(__name__) + +profile_bp = Blueprint("profile_bp", __name__) + + +@profile_bp.route("/me", methods=["GET"]) +@jwt_required() +@role_required("REGISTERED", "ADMIN") +def get_profile(): + user_id = int(get_jwt_identity()) + user = UserRepository.get_by_id(user_id) + if not user: + return error_response(code="NOT_FOUND", message="Usuario no encontrado.", status_code=404) + + return success_response(data={ + "id": user.id, + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "role": user.role, + "intent": user.intent, + "created_at": user.created_at.isoformat() if user.created_at else None, + }) + + +@profile_bp.route("/me", methods=["PATCH"]) +@jwt_required() +@role_required("REGISTERED", "ADMIN") +def update_profile(): + try: + data = UpdateProfileSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + user_id = int(get_jwt_identity()) + user = ProfileService.update_profile(user_id, data) + if not user: + return error_response(code="NOT_FOUND", message="Usuario no encontrado.", status_code=404) + + return success_response(data={ + "id": user.id, + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "role": user.role, + "intent": user.intent, + "created_at": user.created_at.isoformat() if user.created_at else None, + }) + + +@profile_bp.route("/skill-gap", methods=["GET"]) +@jwt_required() +@role_required("REGISTERED", "ADMIN") +def get_skill_gap(): + user_id = int(get_jwt_identity()) + result = ProfileService.get_skill_gap(user_id) + return success_response(data=result) + + +@profile_bp.route("/skills", methods=["POST"]) +@jwt_required() +@role_required("REGISTERED", "ADMIN") +def add_skill(): + try: + data = AddSkillSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + skill_id = data["skill_id"] + skill = SkillRepository.get_by_id(skill_id) + if not skill: + return error_response(code="SKILL_NOT_FOUND", message="La habilidad no existe.", status_code=404) + + user_id = int(get_jwt_identity()) + UserSkillRepository.add_skill(user_id, skill_id) + return success_response(data={"skill_id": skill_id, "name": skill.name}, status_code=201) + + +@profile_bp.route("/skills/", methods=["DELETE"]) +@jwt_required() +@role_required("REGISTERED", "ADMIN") +def remove_skill(skill_id: int): + user_id = int(get_jwt_identity()) + # La eliminacion es idempotente dado qué responde 200 tanto si existia la relacion como si no. + UserSkillRepository.remove_skill(user_id, skill_id) + return success_response(data={"message": "Habilidad eliminada del perfil."}) + + +@profile_bp.route("/change-password", methods=["POST"]) +@jwt_required() +@role_required("REGISTERED", "ADMIN") +def change_password(): + try: + data = ChangePasswordSchema().load(request.get_json() or {}) + except ValidationError as err: + return error_response(code="VALIDATION_ERROR", message=err.messages, status_code=422) + + user_id = int(get_jwt_identity()) + try: + ProfileService.change_password( + user_id, + data["current_password"], + data["new_password"], + ) + except ValueError as e: + if str(e) == "INVALID_CREDENTIALS": + return error_response( + code="INVALID_CREDENTIALS", + message="La contrasena actual es incorrecta.", + status_code=400, + ) + raise + + return success_response(data={"message": "Contrasena actualizada correctamente."}) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 50fff6a..7a80350 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -13,19 +13,24 @@ class User(db.Model): password_hash = db.Column(db.String(255), nullable=True) role = db.Column(db.String(20), nullable=False) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) - # Null para usuarios exclusivamente OAuth (sin contraseña propia). El blocklist callback trata null como "sin restricción" — esos usuarios nunca se desloguean por este mecanismo porque no tienen contraseña que cambiar. + # Null para usuarios exclusivamente OAuth (sin contraseña propia). El blocklist callback trata null como "sin restricción"; esos usuarios nunca se desloguean por este mecanismo porque no tienen contraseña que cambiar. password_changed_at = db.Column(db.DateTime, nullable=True) + # Null es el estado valido para "sin definir"; el valor se puede completar mas adelante desde el perfil. + intent = db.Column(db.String(20), nullable=True) alerts = db.relationship("Alert", backref="user", lazy=True) backups = db.relationship("Backup", backref="user", lazy=True) oauth_accounts = db.relationship("OAuthAccount", backref="user", lazy=True) user_skills = db.relationship("UserSkill", backref="user", lazy=True) - # Restringimos los roles permitidos directamente en la base de datos por seguridad + # Restringimos los roles y los intents permitidos directamente en la base de datos por seguridad __table_args__ = ( db.CheckConstraint( "role IN ('REGISTERED', 'ADMIN')", name="chk_users_role" ), + db.CheckConstraint( + "intent IS NULL OR intent IN ('ESTUDIANTE', 'RECLUTADOR')", name="chk_users_intent" + ), ) def __repr__(self): diff --git a/backend/app/repositories/user_skill_repository.py b/backend/app/repositories/user_skill_repository.py new file mode 100644 index 0000000..091a102 --- /dev/null +++ b/backend/app/repositories/user_skill_repository.py @@ -0,0 +1,39 @@ +from app.extensions import db +from app.models.user_skill import UserSkill +from app.models.skill import Skill +from sqlalchemy.exc import IntegrityError + + +class UserSkillRepository: + # Encapsula el acceso a datos de la tabla de relacion usuario-habilidad. Usa metodos idem potentes para que el controlador no tenga que verificar existencia antes de operar. + + @classmethod + def get_skills_by_user(cls, user_id: int) -> list: + # Hacemos join con Skill para traer el nombre canonico en una sola consulta, evitando N+1 queries. + return db.session.execute( + db.select(UserSkill, Skill) + .join(Skill, Skill.id == UserSkill.skill_id) + .filter(UserSkill.user_id == user_id) + ).all() + + @classmethod + def add_skill(cls, user_id: int, skill_id: int) -> bool: + # Intentamos insertar; si ya existe la llave compuesta, atrapamos la excepcion de integridad y devolvemos False sin lanzar, para que el endpoint POST sea naturalmente idempotente. + entry = UserSkill(user_id=user_id, skill_id=skill_id) + db.session.add(entry) + try: + db.session.commit() + return True + except IntegrityError: + db.session.rollback() + return False + + @classmethod + def remove_skill(cls, user_id: int, skill_id: int) -> bool: + # Eliminamos si existe, sin error si no existe, para que DELETE sea idempotente. + entry = db.session.get(UserSkill, (user_id, skill_id)) + if entry: + db.session.delete(entry) + db.session.commit() + return True + return False diff --git a/backend/app/schemas/profile_schema.py b/backend/app/schemas/profile_schema.py new file mode 100644 index 0000000..c81d3ad --- /dev/null +++ b/backend/app/schemas/profile_schema.py @@ -0,0 +1,49 @@ +from marshmallow import Schema, fields, validate, validates_schema, ValidationError + +from app.schemas.auth_schema import validate_password_strength + + +class UpdateProfileSchema(Schema): + first_name = fields.String( + load_default=None, + validate=validate.Length(min=1, max=50), + ) + last_name = fields.String( + load_default=None, + validate=validate.Length(min=1, max=50), + ) + intent = fields.String( + load_default=None, + validate=validate.OneOf( + ["ESTUDIANTE", "RECLUTADOR"], + error="El intent debe ser ESTUDIANTE o RECLUTADOR.", + ), + allow_none=True, + ) + + @validates_schema + def require_at_least_one_field(self, data, **kwargs): + # Un PATCH sin ningun campo modificado no tiene sentido funcional, lo rechazamos con un mensaje claro. + if not any(v is not None for v in data.values()): + raise ValidationError("Se requiere al menos un campo para actualizar el perfil.") + + +class AddSkillSchema(Schema): + skill_id = fields.Integer( + required=True, + strict=True, + error_messages={"required": "El skill_id es obligatorio."}, + ) + + +class ChangePasswordSchema(Schema): + current_password = fields.String( + required=True, + error_messages={"required": "La contrasena actual es obligatoria."}, + ) + # Reutilizamos el validador de complejidad importandolo directamente desde auth_schema, no duplicando la logica, para que cualquier cambio futuro a las reglas aplique en ambos flujos. + new_password = fields.String( + required=True, + validate=[validate.Length(min=8, max=128), validate_password_strength], + error_messages={"required": "La nueva contrasena es obligatoria."}, + ) diff --git a/backend/app/services/profile_service.py b/backend/app/services/profile_service.py new file mode 100644 index 0000000..4596a34 --- /dev/null +++ b/backend/app/services/profile_service.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone + +from app.repositories.user_repository import UserRepository +from app.repositories.user_skill_repository import UserSkillRepository +from app.repositories.trend_snapshot_repository import TrendSnapshotRepository +from app.utils.hash import hash_password, verify_password + + +class ProfileService: + + @classmethod + def get_skill_gap(cls, user_id: int) -> dict: + # Cargamos primero las habilidades del usuario, luego el ranking global completo (sin limite), y construimos las dos listas; lo que ya tiene y lo que le falta del top de la industria + user_skill_rows = UserSkillRepository.get_skills_by_user(user_id) + user_skill_ids = {row.UserSkill.skill_id for row in user_skill_rows} + user_skills_by_id = {row.UserSkill.skill_id: row.Skill for row in user_skill_rows} + + # Usamos get_top_skills con un limite alto para obtener el ranking completo disponible + top_snapshots = TrendSnapshotRepository.get_top_skills(limit=50) + + mis_habilidades = [] + brechas = [] + + for rank_index, snapshot in enumerate(top_snapshots, start=1): + skill_entry = { + "skill_id": snapshot.skill_id, + "name": snapshot.skill.name if snapshot.skill else None, + "demand_count": snapshot.demand_count, + "ranking_position": rank_index, + } + if snapshot.skill_id in user_skill_ids: + mis_habilidades.append(skill_entry) + else: + brechas.append(skill_entry) + + return { + "mis_habilidades": mis_habilidades, + "brechas": brechas, + } + + @classmethod + def update_profile(cls, user_id: int, data: dict) -> object: + user = UserRepository.get_by_id(user_id) + if not user: + return None + + # Solo actualizamos los campos que llegan en data; los ausentes quedan intactos. + if data.get("first_name") is not None: + user.first_name = data["first_name"] + if data.get("last_name") is not None: + user.last_name = data["last_name"] + if "intent" in data: + # intent puede llegar explicitamente como None para "borrar" el valor + user.intent = data["intent"] + + return UserRepository.save(user) + + @classmethod + def change_password(cls, user_id: int, current_password: str, new_password: str) -> None: + user = UserRepository.get_by_id(user_id) + + # Rechazamos si la cuenta no tiene contrasena propia (solo-OAuth) antes de intentar bcrypt. + if not user or user.password_hash is None: + raise ValueError("INVALID_CREDENTIALS") + + if not verify_password(current_password, user.password_hash): + raise ValueError("INVALID_CREDENTIALS") + + user.password_hash = hash_password(new_password) + # Actualizamos password_changed_at para que el blocklist callback invalide los tokens anteriores. + user.password_changed_at = datetime.now(timezone.utc) + UserRepository.save(user) diff --git a/backend/migrations/versions/050a32090a02_add_intent_to_users.py b/backend/migrations/versions/050a32090a02_add_intent_to_users.py new file mode 100644 index 0000000..b19ea4c --- /dev/null +++ b/backend/migrations/versions/050a32090a02_add_intent_to_users.py @@ -0,0 +1,37 @@ +"""add intent to users + +Revision ID: 050a32090a02 +Revises: 83e1f6043b93 +Create Date: 2026-06-30 18:01:01.970181 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '050a32090a02' +down_revision = '83e1f6043b93' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('intent', sa.String(length=20), nullable=True)) + + op.create_check_constraint( + 'chk_users_intent', 'users', + "intent IS NULL OR intent IN ('ESTUDIANTE', 'RECLUTADOR')" + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('chk_users_intent', 'users', type_='check') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('intent') + + # ### end Alembic commands ### diff --git a/frontend/assets/css/layouts/_grid.css b/frontend/assets/css/layouts/_grid.css index f5e1c53..b00529c 100644 --- a/frontend/assets/css/layouts/_grid.css +++ b/frontend/assets/css/layouts/_grid.css @@ -1 +1,13 @@ -/* _grid.css — SkillStat */ \ No newline at end of file +/* Clases de grilla genericas reutilizables. Elegimos 1024px como breakpoint en lugar de 768px porque en tablets (portrait) los formularios de una columna siguen ofreciendo mejor experiencia de usuario. */ + +.grid-2-col { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-6); +} + +@media (min-width: 1024px) { + .grid-2-col { + grid-template-columns: 3fr 2fr; + } +} diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css index 4015f54..1676a7e 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -21,7 +21,7 @@ /* Instanciamos los layouts (descomente conforme se crea cada archivo) */ @import url("layouts/_containers.css"); -/* @import url('layouts/_grid.css'); */ +@import url("layouts/_grid.css"); /* @import url('layouts/_navbar-layout.css'); */ /* Instanciamos las páginas (descomente conforme se crea cada archivo) */ @@ -30,4 +30,5 @@ @import url("pages/_auth.css"); /* @import url('pages/_alertas.css'); */ @import url("pages/_comparar.css"); +@import url("pages/_perfil.css"); /* @import url('pages/_admin.css'); */ diff --git a/frontend/assets/css/pages/_index.css b/frontend/assets/css/pages/_index.css index afa88ce..39728f9 100644 --- a/frontend/assets/css/pages/_index.css +++ b/frontend/assets/css/pages/_index.css @@ -58,6 +58,30 @@ .cta-section .btn { width: auto; } + .carousel-section .carousel { + max-width: 600px; + } +} + +@media (min-width: 1024px) { + .carousel-section .carousel { + max-width: 100%; + } + .panorama-preview .browser-chrome { + max-width: 860px; + } + .hero__heading { + max-width: 900px; + } + .hero__subheading { + max-width: 720px; + } +} + +@media (min-width: 1440px) { + .panorama-preview .browser-chrome { + max-width: 1000px; + } } .value-props { @@ -78,3 +102,43 @@ margin-inline: auto; text-align: left; } + +/* Top skills landing section */ +.top-skills-section { + padding-block: var(--space-12); + text-align: center; +} + +.top-skills-section__eyebrow { + display: inline-flex; + align-items: center; + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-md); + background-color: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); + margin-bottom: var(--space-6); +} + +.top-skills-section h2 { + margin-bottom: var(--space-8); +} + +.top-skills-section__chips { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--space-3); + margin-bottom: var(--space-8); +} + +.top-skills-section__link { + display: inline-block; + color: var(--color-primary); + text-decoration: none; + font-weight: 500; +} + +.top-skills-section__link:hover { + text-decoration: underline; +} + diff --git a/frontend/assets/css/pages/_perfil.css b/frontend/assets/css/pages/_perfil.css new file mode 100644 index 0000000..b8b144a --- /dev/null +++ b/frontend/assets/css/pages/_perfil.css @@ -0,0 +1,115 @@ +/* Estilos especificos para la pagina de perfil. Mantenemos este archivo separado para no polucionar el CSS global con reglas que solo se usan aqui, y evitamos absolutamente el CSS inline para mantener la especificidad y el mantenimiento bajo control. */ + +.profile-loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 50vh; +} + +.profile-nav { + margin-bottom: var(--space-8); +} + +.profile-section { + margin-top: var(--space-6); +} + +.profile-section > h2 { + margin-bottom: var(--space-4); +} + +.profile-section > .text-body { + margin-bottom: var(--space-6); +} + +.profile-form { + width: 100%; +} + +.profile-card-context { + padding: var(--space-6); + border-radius: var(--radius-md); + height: fit-content; + position: sticky; + top: var(--space-8); +} + +.profile-card-context ul { + list-style: disc; + margin-left: var(--space-4); +} + +.profile-skills-list { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.profile-skills-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-8); +} + +@media (min-width: 1024px) { + .profile-skills-grid { + grid-template-columns: 2fr 1fr; + } + + .profile-skills-grid #brechas-container { + max-height: 320px; + overflow-y: auto; + } + + .profile-form .password-checklist { + flex-direction: row; + flex-wrap: wrap; + gap: var(--space-4); + } +} + +.profile-skills-sidebar { + padding: var(--space-4); + border-radius: var(--radius-md); + height: fit-content; +} + +.chip { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + background-color: var(--color-bg-surface); + border: 1px solid var(--color-border-subtle); + font-size: var(--text-sm); +} + +.chip button { + background: none; + border: none; + cursor: pointer; + display: flex; + align-items: center; + padding: 0; + color: var(--color-text-disabled); +} + +.chip button:hover { + color: var(--color-text-primary); +} + +.chip__badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 var(--space-2); + border-radius: var(--radius-full); + background-color: var(--color-primary); + color: #fff; + font-size: var(--text-xs); + font-weight: 600; + line-height: 1.4; + min-width: 1.5rem; +} diff --git a/frontend/assets/js/api/client.js b/frontend/assets/js/api/client.js index 4ad0f00..cf128dd 100644 --- a/frontend/assets/js/api/client.js +++ b/frontend/assets/js/api/client.js @@ -44,10 +44,50 @@ async function apiGet(endpoint) { return json.data; } +function getCsrfToken() { + // Flask-JWT-Extended emite la cookie csrf_access_token sin la bandera httpOnly intencionalmente. Esto nos permite leerla desde JavaScript en el navegador y adjuntarla como el header X-CSRF-TOKEN en las peticiones que mutan estado, completando el patrón Double Submit Cookie para protegernos de ataques CSRF sin requerir que nuestro backend de API mantenga estado de sesiones. + const match = document.cookie.match( + new RegExp("(^| )csrf_access_token=([^;]+)"), + ); + if (match) { + return decodeURIComponent(match[2]); + } + return null; +} + async function apiPost(endpoint, body) { + const headers = { "Content-Type": "application/json" }; + const csrfToken = getCsrfToken(); + if (csrfToken) { + headers["X-CSRF-TOKEN"] = csrfToken; + } + const response = await fetch(`${API_BASE_URL}${endpoint}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: headers, + credentials: "include", + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorBody = await parseErrorBody(response); + throw buildApiError(errorBody, response, endpoint); + } + + const json = await response.json(); + return json.data; +} + +async function apiPatch(endpoint, body) { + const headers = { "Content-Type": "application/json" }; + const csrfToken = getCsrfToken(); + if (csrfToken) { + headers["X-CSRF-TOKEN"] = csrfToken; + } + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + method: "PATCH", + headers: headers, credentials: "include", body: JSON.stringify(body), }); @@ -60,3 +100,30 @@ async function apiPost(endpoint, body) { const json = await response.json(); return json.data; } + +async function apiDelete(endpoint) { + const headers = {}; + const csrfToken = getCsrfToken(); + if (csrfToken) { + headers["X-CSRF-TOKEN"] = csrfToken; + } + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + method: "DELETE", + headers: headers, + credentials: "include", + }); + + if (!response.ok) { + const errorBody = await parseErrorBody(response); + throw buildApiError(errorBody, response, endpoint); + } + + // DELETE podria devolver 204 No Content o un JSON con datos, lo manejamos sin fallar. + try { + const json = await response.json(); + return json.data || null; + } catch { + return null; + } +} diff --git a/frontend/assets/js/components/carousel.init.js b/frontend/assets/js/components/carousel.init.js index b56a2a2..24ec04b 100644 --- a/frontend/assets/js/components/carousel.init.js +++ b/frontend/assets/js/components/carousel.init.js @@ -26,11 +26,20 @@ function goToSlide(index) { const slide = slides[index]; if (!slide) return; - slide.scrollIntoView({ - behavior: prefersReducedMotion ? "auto" : "smooth", - inline: "start", - block: "nearest", - }); + // Solo ejecuta scrollIntoView si el carousel esta dentro del viewport. + // Si esta fuera (por ejemplo el usuario bajo al footer), avanza el indice + // logico sin mover la pagina, para que al volver vea el slide correcto. + const rect = carousel.getBoundingClientRect(); + const inViewport = rect.top >= 0 && rect.bottom <= window.innerHeight; + if (inViewport) { + slide.scrollIntoView({ + behavior: prefersReducedMotion ? "auto" : "smooth", + inline: "start", + block: "nearest", + }); + } else { + setActiveDot(index); + } } dots.forEach(function (dot, index) { @@ -60,7 +69,7 @@ }); function startAutoRotate() { - if (prefersReducedMotion) return; + if (prefersReducedMotion || autoRotateTimer) return; autoRotateTimer = setInterval(function () { const nextIndex = (currentIndex + 1) % slides.length; goToSlide(nextIndex); @@ -74,9 +83,21 @@ } } - carousel.addEventListener("mouseenter", stopAutoRotate); - carousel.addEventListener("mouseleave", startAutoRotate); - carousel.addEventListener("touchstart", stopAutoRotate, { passive: true }); + // IntersectionObserver sobre el carousel completo: detiene el auto-rotate + // cuando el carousel sale del viewport y lo reanuda cuando entra. + // Esto reemplaza los listeners de mouseenter/mouseleave/touchstart. + const visibilityObserver = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + startAutoRotate(); + } else { + stopAutoRotate(); + } + }); + }, + { threshold: 0.1 }, + ); - startAutoRotate(); + visibilityObserver.observe(carousel); })(); diff --git a/frontend/assets/js/pages/index.js b/frontend/assets/js/pages/index.js index 0756c43..60559a5 100644 --- a/frontend/assets/js/pages/index.js +++ b/frontend/assets/js/pages/index.js @@ -2,7 +2,7 @@ async function initIndexPage() { try { const [summary, topSkills] = await Promise.all([ getSummary(), - getTopSkills(5), + getTopSkills(10), ]); const topSkill = topSkills[0]; @@ -27,6 +27,8 @@ async function initIndexPage() { formatNumber(summary.total_jobs); document.querySelector('[data-metric="preview-skill"]').textContent = topSkill.name; + + renderTopSkillsChips(topSkills); } catch (error) { console.error("No se pudieron cargar los datos del Panorama:", error); @@ -35,7 +37,41 @@ async function initIndexPage() { element.textContent = "No disponible"; } }); + + // La seccion de top skills es aditiva; si falla, se oculta sin romper el resto. + const topSkillsSection = document.getElementById("top-skills-section"); + if (topSkillsSection) topSkillsSection.hidden = true; + } +} + +function renderTopSkillsChips(skills) { + const container = document.getElementById("top-skills-chips"); + if (!container || !Array.isArray(skills) || skills.length === 0) { + const section = document.getElementById("top-skills-section"); + if (section) section.hidden = true; + return; } + + const fragment = document.createDocumentFragment(); + skills.slice(0, 10).forEach(function (skill) { + const chip = document.createElement("div"); + chip.className = "chip"; + chip.setAttribute("role", "listitem"); + + const name = document.createElement("span"); + name.textContent = skill.name; + + const badge = document.createElement("span"); + badge.className = "chip__badge"; + badge.textContent = formatNumber(skill.demand_count); + badge.setAttribute("aria-label", `${formatNumber(skill.demand_count)} vacantes`); + + chip.appendChild(name); + chip.appendChild(badge); + fragment.appendChild(chip); + }); + + container.appendChild(fragment); } document.addEventListener("DOMContentLoaded", initIndexPage); diff --git a/frontend/assets/js/pages/perfil.js b/frontend/assets/js/pages/perfil.js new file mode 100644 index 0000000..dab80f7 --- /dev/null +++ b/frontend/assets/js/pages/perfil.js @@ -0,0 +1,311 @@ +// Datos de estado global para comparar si hay cambios en el formulario "Mis datos" +let originalProfileData = {}; + +const PASSWORD_RULES = { + length: (value) => value.length >= 8, + upper: (value) => /[A-Z]/.test(value), + number: (value) => /\d/.test(value), + special: (value) => /[^A-Za-z0-9]/.test(value), +}; + +function updatePasswordChecklist(password) { + Object.entries(PASSWORD_RULES).forEach(([rule, check]) => { + const item = document.querySelector(`[data-rule="${rule}"]`); + if (!item) return; + item.classList.toggle("is-valid", check(password)); + }); +} + +function updatePasswordSubmitState() { + const form = document.getElementById("seguridad-form"); + if (!form) return; + + const password = form.new_password.value; + const allRulesPass = Object.values(PASSWORD_RULES).every((check) => + check(password), + ); + const passwordsMatch = + password.length > 0 && password === form.confirm_password.value; + + const btn = document.getElementById("btn-save-password"); + if (btn) { + btn.disabled = !(allRulesPass && passwordsMatch); + } +} + +function showInlineMessage(msgElement, text, isSuccess) { + msgElement.textContent = text; + msgElement.hidden = false; + if (isSuccess) { + msgElement.style.color = "var(--color-accent-green)"; + msgElement.style.borderColor = "var(--color-accent-green)"; + msgElement.style.backgroundColor = "rgba(16, 185, 129, 0.1)"; + } else { + msgElement.style.color = ""; + msgElement.style.borderColor = ""; + msgElement.style.backgroundColor = ""; + } +} + +async function initProfilePage() { + try { + const profileData = await apiGet("/profile/me"); + originalProfileData = profileData; + + // Poblar Mis datos + const form = document.getElementById("datos-form"); + form.first_name.value = profileData.first_name || ""; + form.last_name.value = profileData.last_name || ""; + form.intent.value = profileData.intent || ""; + + // Poblar tarjeta contextual + const emailEl = document.querySelector("[data-profile-email]"); + if (emailEl) emailEl.textContent = profileData.email || "No disponible"; + + const roleEl = document.querySelector("[data-profile-role]"); + if (roleEl) { + const rolesMap = { REGISTERED: "Registrado", ADMIN: "Administrador" }; + roleEl.textContent = + rolesMap[profileData.role] || profileData.role || "Desconocido"; + } + + const sinceEl = document.querySelector("[data-profile-since]"); + if (sinceEl && profileData.created_at) { + const date = new Date(profileData.created_at); + sinceEl.textContent = date.toLocaleDateString("es-ES", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } else if (sinceEl) { + sinceEl.textContent = "No disponible"; + } + + // Ocultar loader y mostrar pagina principal + document.getElementById("profile-loading").style.display = "none"; + document.getElementById("profile-main").hidden = false; + + // Cargar habilidades + await loadSkills(); + } catch (error) { + if (error.status === 401) { + window.location.href = "register.html"; + } else { + console.error("Error cargando perfil:", error); + // Fallback + document.getElementById("profile-loading").innerHTML = + `

No se pudo cargar el perfil.

`; + } + } +} + +async function handleDatosSubmit(e) { + e.preventDefault(); + const form = e.target; + const btn = document.getElementById("btn-save-datos"); + const msg = document.getElementById("datos-msg"); + + const currentData = { + first_name: form.first_name.value, + last_name: form.last_name.value, + intent: form.intent.value || null, + }; + + const changes = {}; + for (const key in currentData) { + if (currentData[key] !== originalProfileData[key]) { + changes[key] = currentData[key]; + } + } + + if (Object.keys(changes).length === 0) { + showInlineMessage(msg, "No hay cambios que guardar.", true); + return; + } + + const originalText = btn.textContent; + btn.textContent = "Guardando..."; + btn.disabled = true; + msg.hidden = true; + + try { + const response = await apiPatch("/profile/me", changes); + originalProfileData = response; // Actualizar con nueva data (incluye los campos modificados) + showInlineMessage(msg, "Perfil actualizado correctamente.", true); + } catch (error) { + showInlineMessage(msg, error.message, false); + } finally { + btn.textContent = originalText; + btn.disabled = false; + } +} + +async function handlePasswordSubmit(e) { + e.preventDefault(); + const form = e.target; + const btn = document.getElementById("btn-save-password"); + const msg = document.getElementById("seguridad-msg"); + + const originalText = btn.textContent; + btn.textContent = "Actualizando..."; + btn.disabled = true; + msg.hidden = true; + + try { + await apiPost("/profile/change-password", { + current_password: form.current_password.value, + new_password: form.new_password.value, + }); + + showInlineMessage(msg, "Contraseña actualizada. Redirigiendo...", true); + + setTimeout(() => { + window.location.href = "register.html"; + }, 2000); + } catch (error) { + if (error.code === "INVALID_CREDENTIALS") { + showInlineMessage(msg, "La contraseña actual no es correcta", false); + } else { + showInlineMessage(msg, error.message, false); + } + btn.disabled = false; + btn.textContent = originalText; + } +} + +async function loadSkills() { + try { + const gapData = await apiGet("/profile/skill-gap"); + renderSkills(gapData.mis_habilidades, gapData.brechas); + } catch (error) { + console.error("Error cargando habilidades:", error); + } +} + +function renderSkills(misHabilidades, brechas) { + const misContainer = document.getElementById("mis-habilidades-container"); + const misEmpty = document.getElementById("mis-habilidades-empty"); + const brechasContainer = document.getElementById("brechas-container"); + const brechasEmpty = document.getElementById("brechas-empty"); + + misContainer.innerHTML = ""; + brechasContainer.innerHTML = ""; + + if (misHabilidades.length === 0) { + misEmpty.hidden = false; + } else { + misEmpty.hidden = true; + misHabilidades.forEach((skill) => { + const chip = document.createElement("div"); + chip.className = "chip"; + chip.innerHTML = ` + ${skill.name} + + `; + misContainer.appendChild(chip); + }); + } + + if (brechas.length === 0) { + brechasEmpty.hidden = false; + } else { + brechasEmpty.hidden = true; + brechas.forEach((skill) => { + const chip = document.createElement("div"); + chip.className = "chip"; + chip.innerHTML = ` + ${skill.name} + + `; + brechasContainer.appendChild(chip); + }); + } + + // Re-inicializar iconos despues de inyectar HTML si lucide existe globalmente + if (window.lucide && window.lucide.createIcons) { + window.lucide.createIcons(); + } + + // Bind events for buttons + misContainer + .querySelectorAll('[data-action="remove-skill"]') + .forEach((btn) => { + btn.addEventListener("click", () => + handleRemoveSkill(btn.dataset.id, btn), + ); + }); + + brechasContainer + .querySelectorAll('[data-action="add-skill"]') + .forEach((btn) => { + btn.addEventListener("click", () => handleAddSkill(btn.dataset.id, btn)); + }); +} + +async function handleAddSkill(skillId, btnElement) { + btnElement.disabled = true; + try { + await apiPost("/profile/skills", { skill_id: parseInt(skillId) }); + await loadSkills(); + } catch (error) { + console.error("Error agregando habilidad:", error); + btnElement.disabled = false; + } +} + +async function handleRemoveSkill(skillId, btnElement) { + btnElement.disabled = true; + try { + await apiDelete(`/profile/skills/${skillId}`); + await loadSkills(); + } catch (error) { + console.error("Error eliminando habilidad:", error); + btnElement.disabled = false; + } +} + +function bindNavigation() { + const tabs = document.querySelectorAll("[data-nav-section]"); + const sections = document.querySelectorAll("[data-section]"); + + tabs.forEach((tab) => { + tab.addEventListener("click", () => { + // Activar pill + tabs.forEach((t) => t.classList.remove("pill--active")); + tab.classList.add("pill--active"); + + // Mostrar seccion + const targetId = `section-${tab.dataset.navSection}`; + sections.forEach((sec) => { + sec.hidden = sec.id !== targetId; + }); + }); + }); +} + +document.addEventListener("DOMContentLoaded", () => { + bindNavigation(); + initProfilePage(); + + const datosForm = document.getElementById("datos-form"); + if (datosForm) { + datosForm.addEventListener("submit", handleDatosSubmit); + } + + const seguridadForm = document.getElementById("seguridad-form"); + if (seguridadForm) { + seguridadForm.addEventListener("submit", handlePasswordSubmit); + seguridadForm.new_password.addEventListener("input", () => { + updatePasswordChecklist(seguridadForm.new_password.value); + updatePasswordSubmitState(); + }); + seguridadForm.confirm_password.addEventListener( + "input", + updatePasswordSubmitState, + ); + } +}); diff --git a/frontend/views/index.html b/frontend/views/index.html index ba607bf..27ece2c 100644 --- a/frontend/views/index.html +++ b/frontend/views/index.html @@ -304,6 +304,18 @@

+ +
+
+

Skills más demandados ahora

+

Lo que el mercado pide hoy

+
+ +
+ Ver el Panorama completo → +
+
+
diff --git a/frontend/views/perfil.html b/frontend/views/perfil.html new file mode 100644 index 0000000..6943358 --- /dev/null +++ b/frontend/views/perfil.html @@ -0,0 +1,435 @@ + + + + + + + SkillStat - Mi perfil + + + + + + + + + + + + +
+
+
+

Mi perfil

+
+
+ +
+
+
+ + + +
+
+
+ +
+
+ +
+

Mis datos

+
+
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Información de cuenta

+
+ +

Cargando...

+
+
+ +

Cargando...

+
+
+ +

Cargando...

+
+
+
+
+ + + + + + +
+
+
+ + +
+

Cargando perfil...

+
+ + + + + + + +