Spaces:
Running
Running
| <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)) ; | |
| background: var(--vs-panel, rgba(255,255,255,0.85)) ; | |
| 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) ; | |
| } | |
| .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> | |