ValorSentimental / chatbot /src /views /AuthView.vue
iagofp's picture
Fachada DAO, Preparacion Render
f2ee01f
<template>
<v-main class="auth-main">
<div class="auth-blobs">
<div class="blob-1" />
<div class="blob-2" />
</div>
<div class="auth-wrapper">
<!-- Brand -->
<div class="auth-brand" role="button" @click="router.push('/')">
<div class="brand-icon">
<v-icon color="amber-darken-1" size="22">mdi-movie-open-play-outline</v-icon>
</div>
<div>
<p class="brand-kicker">Recomendador emocional de cine</p>
<h1 class="brand-title">Valor Sentimental</h1>
</div>
</div>
<!-- Card -->
<div class="auth-card-wrap">
<v-card class="auth-card" rounded="xl" elevation="0">
<!-- ── Paso 1: Login / Register ───────────────────────── -->
<template v-if="step === 1">
<div class="auth-card-header">
<h2 class="card-title">
{{ activeTab === 'login' ? 'Bienvenido de vuelta' : 'Crear cuenta' }}
</h2>
<p class="card-subtitle">
{{ activeTab === 'login'
? 'Inicia sesiΓ³n para acceder al chat'
: 'RegΓ­strate para guardar tu historial' }}
</p>
</div>
<v-tabs v-model="activeTab" color="primary" density="compact" class="auth-tabs">
<v-tab value="login">Iniciar sesiΓ³n</v-tab>
<v-tab value="register">Registrarse</v-tab>
</v-tabs>
<v-divider />
<div class="form-wrap">
<!-- Login -->
<form v-if="activeTab === 'login'" class="auth-form" @submit.prevent="submitLogin">
<v-text-field
v-model="loginForm.username"
label="Nombre de usuario"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-account-outline"
autocomplete="username"
hide-details="auto"
class="mb-4"
/>
<v-text-field
v-model="loginForm.password"
label="ContraseΓ±a"
:type="showLoginPwd ? 'text' : 'password'"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-lock-outline"
:append-inner-icon="showLoginPwd ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="current-password"
hide-details="auto"
class="mb-4"
@click:append-inner="showLoginPwd = !showLoginPwd"
/>
<v-alert v-if="loginError" type="error" variant="tonal" rounded="lg" density="compact" class="mb-4">
{{ loginError }}
</v-alert>
<v-btn type="submit" color="primary" variant="flat" block rounded="lg" size="large" :loading="loginLoading">
Iniciar sesiΓ³n
</v-btn>
</form>
<!-- Register -->
<form v-else class="auth-form" @submit.prevent="submitRegister">
<v-text-field
v-model="registerForm.username"
label="Nombre de usuario"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-account-outline"
autocomplete="username"
hide-details="auto"
class="mb-4"
/>
<v-text-field
v-model="registerForm.password"
label="ContraseΓ±a"
:type="showRegPwd ? 'text' : 'password'"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-lock-outline"
:append-inner-icon="showRegPwd ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="new-password"
hide-details="auto"
class="mb-4"
@click:append-inner="showRegPwd = !showRegPwd"
/>
<v-alert v-if="registerError" type="error" variant="tonal" rounded="lg" density="compact" class="mb-4">
{{ registerError }}
</v-alert>
<v-btn type="submit" color="primary" variant="flat" block rounded="lg" size="large" :loading="registerLoading">
Crear cuenta
</v-btn>
</form>
</div>
</template>
<!-- ── Paso 2: Onboarding de pelΓ­culas vistas ─────────── -->
<template v-else>
<div class="auth-card-header">
<h2 class="card-title">ΒΏQuΓ© pelΓ­culas ya has visto?</h2>
<p class="card-subtitle">
AΓ±ade algunas y el recomendador aprenderΓ‘ mejor tus gustos desde el primer momento.
</p>
</div>
<v-divider />
<div class="form-wrap onboarding-wrap">
<!-- Buscador -->
<div class="search-row">
<v-text-field
v-model="searchQuery"
label="Buscar pelΓ­cula..."
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-magnify"
hide-details
clearable
@input="onSearchInput"
@click:clear="clearSearch"
/>
</div>
<!-- Resultados de bΓΊsqueda -->
<div v-if="searchResults.length" class="results-list">
<div
v-for="peli in searchResults"
:key="peli.movie_id"
class="result-item"
:class="{ 'result-item--added': isAdded(peli.movie_id) }"
@click="togglePelicula(peli)"
>
<div class="result-info">
<span class="result-title">{{ peli.titulo }}</span>
<span class="result-genre">{{ formatGenre(peli.genero) }}</span>
</div>
<div class="result-right">
<span v-if="peli.rating_mean" class="result-rating">
<v-icon size="13" color="amber-darken-1">mdi-star</v-icon>
{{ peli.rating_mean.toFixed(1) }}
</span>
<v-icon v-if="isAdded(peli.movie_id)" color="primary" size="20">mdi-check-circle</v-icon>
<v-icon v-else size="20" color="grey-lighten-1">mdi-plus-circle-outline</v-icon>
</div>
</div>
</div>
<!-- PelΓ­culas populares (cuando no hay bΓΊsqueda activa) -->
<div v-else-if="!searchQuery" class="popular-section">
<p class="section-label">Populares</p>
<div class="results-list">
<div
v-for="peli in popularMovies"
:key="peli.movie_id"
class="result-item"
:class="{ 'result-item--added': isAdded(peli.movie_id) }"
@click="togglePelicula(peli)"
>
<div class="result-info">
<span class="result-title">{{ peli.titulo }}</span>
<span class="result-genre">{{ formatGenre(peli.genero) }}</span>
</div>
<div class="result-right">
<span v-if="peli.rating_mean" class="result-rating">
<v-icon size="13" color="amber-darken-1">mdi-star</v-icon>
{{ peli.rating_mean.toFixed(1) }}
</span>
<v-icon v-if="isAdded(peli.movie_id)" color="primary" size="20">mdi-check-circle</v-icon>
<v-icon v-else size="20" color="grey-lighten-1">mdi-plus-circle-outline</v-icon>
</div>
</div>
</div>
</div>
<!-- Seleccionadas -->
<div v-if="seleccionadas.length" class="selected-section">
<p class="section-label">AΓ±adidas ({{ seleccionadas.length }})</p>
<div class="chips-wrap">
<v-chip
v-for="peli in seleccionadas"
:key="peli.movie_id"
closable
size="small"
class="chip-pelicula"
@click:close="quitarPelicula(peli.movie_id)"
>
{{ peli.titulo }}
</v-chip>
</div>
</div>
<!-- Acciones -->
<div class="onboarding-actions">
<v-btn
color="primary"
variant="flat"
block
rounded="lg"
size="large"
:loading="onboardingLoading"
@click="finalizarOnboarding"
>
{{ seleccionadas.length ? `Guardar y empezar (${seleccionadas.length})` : 'Empezar sin historial' }}
</v-btn>
</div>
</div>
</template>
</v-card>
</div>
</div>
</v-main>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import API_BASE_URL from "../config.js";
const router = useRouter();
const activeTab = ref("login");
const step = ref(1);
// ── Login ──────────────────────────────────────────────────────────
const loginForm = ref({ username: "", password: "" });
const loginError = ref("");
const loginLoading = ref(false);
const showLoginPwd = ref(false);
// ── Register ───────────────────────────────────────────────────────
const registerForm = ref({ username: "", password: "" });
const registerError = ref("");
const registerLoading = ref(false);
const showRegPwd = ref(false);
// ── Onboarding ─────────────────────────────────────────────────────
const popularMovies = ref([]);
const searchQuery = ref("");
const searchResults = ref([]);
const seleccionadas = ref([]);
const onboardingLoading = ref(false);
let searchTimer = null;
// Credenciales guardadas tras el registro, para el onboarding
let _pendingUserId = "";
let _pendingToken = "";
async function submitLogin() {
loginError.value = "";
if (!loginForm.value.username || !loginForm.value.password) {
loginError.value = "Completa todos los campos.";
return;
}
loginLoading.value = true;
try {
const res = await fetch(`${API_BASE_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: loginForm.value.username, password: loginForm.value.password }),
});
const data = await res.json();
if (!res.ok) { loginError.value = data.error || "Credenciales incorrectas."; return; }
localStorage.setItem("vs_token", data.token);
localStorage.setItem("vs_user_id", data.user_id);
localStorage.setItem("vs_username", data.username);
router.push("/chat");
} catch {
loginError.value = "Error de conexiΓ³n con el servidor.";
} finally {
loginLoading.value = false;
}
}
async function submitRegister() {
registerError.value = "";
if (!registerForm.value.username || !registerForm.value.password) {
registerError.value = "Usuario y contraseΓ±a son obligatorios.";
return;
}
if (registerForm.value.username.length < 3) {
registerError.value = "El usuario debe tener al menos 3 caracteres.";
return;
}
if (registerForm.value.password.length < 6) {
registerError.value = "La contraseΓ±a debe tener al menos 6 caracteres.";
return;
}
registerLoading.value = true;
try {
const res = await fetch(`${API_BASE_URL}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: registerForm.value.username, password: registerForm.value.password }),
});
const data = await res.json();
if (!res.ok) { registerError.value = data.error || "Error al crear la cuenta."; return; }
// Guardamos credenciales en localStorage ya, pero vamos al paso 2
localStorage.setItem("vs_token", data.token);
localStorage.setItem("vs_user_id", data.user_id);
localStorage.setItem("vs_username", data.username);
_pendingUserId = data.user_id;
_pendingToken = data.token;
await cargarPopulares();
step.value = 2;
} catch {
registerError.value = "Error de conexiΓ³n con el servidor.";
} finally {
registerLoading.value = false;
}
}
async function cargarPopulares() {
try {
const res = await fetch(`${API_BASE_URL}/peliculas/populares?limit=20`);
if (res.ok) {
const data = await res.json();
popularMovies.value = data.items || [];
}
} catch {
// silencioso β€” no bloqueamos el onboarding si falla
}
}
function onSearchInput() {
clearTimeout(searchTimer);
if (!searchQuery.value || searchQuery.value.trim().length < 2) {
searchResults.value = [];
return;
}
searchTimer = setTimeout(async () => {
try {
const q = encodeURIComponent(searchQuery.value.trim());
const res = await fetch(`${API_BASE_URL}/peliculas/buscar?q=${q}&limit=15`);
if (res.ok) {
const data = await res.json();
searchResults.value = data.items || [];
}
} catch {
searchResults.value = [];
}
}, 300);
}
function clearSearch() {
searchQuery.value = "";
searchResults.value = [];
}
function isAdded(movieId) {
return seleccionadas.value.some(p => p.movie_id === movieId);
}
function togglePelicula(peli) {
if (isAdded(peli.movie_id)) {
quitarPelicula(peli.movie_id);
} else {
seleccionadas.value.push(peli);
}
}
function quitarPelicula(movieId) {
seleccionadas.value = seleccionadas.value.filter(p => p.movie_id !== movieId);
}
function formatGenre(genero) {
if (!genero || genero === "(no genres listed)") return "";
return genero.split("|").slice(0, 3).join(" Β· ");
}
async function finalizarOnboarding() {
if (!seleccionadas.value.length) {
router.push("/chat");
return;
}
onboardingLoading.value = true;
try {
await fetch(`${API_BASE_URL}/onboarding/historial`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: _pendingUserId,
token: _pendingToken,
peliculas: seleccionadas.value.map(p => ({
movie_id: p.movie_id,
titulo: p.titulo,
genero: p.genero,
})),
}),
});
} catch {
// Si falla el onboarding no bloqueamos al usuario
} finally {
onboardingLoading.value = false;
}
router.push("/chat");
}
</script>
<style scoped>
.auth-main {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.auth-blobs {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 0;
}
.blob-1 {
position: absolute;
top: -15%;
right: -8%;
width: 520px;
height: 520px;
border-radius: 50%;
background: radial-gradient(circle, rgba(102,126,234,0.11) 0%, transparent 70%);
animation: liquidFloat 12s ease-in-out infinite;
}
.blob-2 {
position: absolute;
bottom: -18%;
left: -6%;
width: 440px;
height: 440px;
border-radius: 50%;
background: radial-gradient(circle, rgba(118,75,162,0.08) 0%, transparent 70%);
animation: liquidFloat 15s ease-in-out infinite reverse;
}
.auth-wrapper {
position: relative;
z-index: 1;
width: 100%;
max-width: 480px;
padding: 24px 16px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
}
.auth-brand {
display: flex;
align-items: center;
gap: 14px;
cursor: pointer;
transition: opacity 0.2s ease;
}
.auth-brand:hover { opacity: 0.8; }
.brand-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(212,175,55,0.1);
border: 1px solid rgba(212,175,55,0.25);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.brand-kicker {
margin: 0;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--vs-gold, #d4af37);
opacity: 0.9;
}
.brand-title {
margin: 3px 0 0;
font-family: "Fraunces", serif;
font-size: 1.35rem;
font-weight: 700;
line-height: 1.2;
color: var(--vs-text, #1a1a2e);
letter-spacing: -0.01em;
}
.auth-card-wrap { width: 100%; }
.auth-card {
border: 1.5px solid var(--vs-border, rgba(102,126,234,0.15)) !important;
background: var(--vs-panel, rgba(255,255,255,0.85)) !important;
backdrop-filter: blur(16px) saturate(150%);
-webkit-backdrop-filter: blur(16px) saturate(150%);
box-shadow: 0 8px 40px rgba(102,126,234,0.12), 0 2px 8px rgba(0,0,0,0.06) !important;
}
.auth-card-header {
padding: 28px 28px 16px;
}
.card-title {
font-family: "Fraunces", serif;
font-size: 1.35rem;
font-weight: 700;
color: var(--vs-text, #1a1a2e);
margin: 0 0 6px;
letter-spacing: -0.01em;
}
.card-subtitle {
font-size: 0.875rem;
color: var(--vs-muted, #6b7280);
margin: 0;
line-height: 1.5;
}
.auth-tabs {
padding: 0 16px;
}
.form-wrap {
padding: 24px 28px 28px;
}
.auth-form { display: flex; flex-direction: column; }
/* ── Onboarding ─────────────────────────────────────────────────── */
.onboarding-wrap {
display: flex;
flex-direction: column;
gap: 16px;
}
.search-row { width: 100%; }
.results-list {
border: 1px solid var(--vs-border, rgba(102,126,234,0.15));
border-radius: 10px;
overflow: hidden;
max-height: 280px;
overflow-y: auto;
}
.result-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
cursor: pointer;
transition: background 0.15s ease;
border-bottom: 1px solid rgba(102,126,234,0.07);
gap: 8px;
}
.result-item:last-child { border-bottom: none; }
.result-item:hover { background: rgba(102,126,234,0.06); }
.result-item--added { background: rgba(102,126,234,0.08); }
.result-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.result-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--vs-text, #1a1a2e);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
}
.result-genre {
font-size: 0.72rem;
color: var(--vs-muted, #6b7280);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
}
.result-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.result-rating {
display: flex;
align-items: center;
gap: 2px;
font-size: 0.78rem;
color: var(--vs-muted, #6b7280);
white-space: nowrap;
}
.section-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--vs-muted, #6b7280);
margin: 0 0 8px;
}
.popular-section, .selected-section { width: 100%; }
.chips-wrap {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip-pelicula {
font-size: 0.78rem;
}
.onboarding-actions { width: 100%; }
@media (max-width: 480px) {
.auth-card-header { padding: 20px 20px 12px; }
.form-wrap { padding: 20px 20px 24px; }
.result-title, .result-genre { max-width: 180px; }
}
</style>