Initial commit: SIBU 2.0 MISSION
This commit is contained in:
583
frontend/src/components/AppHeader.vue
Normal file
583
frontend/src/components/AppHeader.vue
Normal file
@ -0,0 +1,583 @@
|
||||
<template>
|
||||
<header class="app-header" :class="{ 'admin-mode': authStore.isAdmin }">
|
||||
<div class="header-left">
|
||||
<button class="menu-btn-custom" @click="toggleMenu">
|
||||
<span class="icon">
|
||||
<svg viewBox="0 0 80 75" width="24" height="24">
|
||||
<rect width="80" height="15" fill="currentColor" rx="10"></rect>
|
||||
<rect y="30" width="80" height="15" fill="currentColor" rx="10"></rect>
|
||||
<rect y="60" width="80" height="15" fill="currentColor" rx="10"></rect>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="authStore.isAdmin" class="admin-badge">ADMIN</div>
|
||||
<div v-if="authStore.isDriver" class="driver-badge">CONDUCTOR</div>
|
||||
|
||||
<ReportModal :is-open="showReportModal" @close="showReportModal = false" />
|
||||
|
||||
<!-- Menu Overlay -->
|
||||
<Transition name="overlay-fade">
|
||||
<div v-if="showMenu" class="menu-overlay" @click="showMenu = false"></div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="menu-slide">
|
||||
<div v-if="showMenu" class="menu-dropdown">
|
||||
<div class="menu-header" v-if="authStore.isAuthenticated">
|
||||
<span class="user-name">{{ authStore.userName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Unified Menu Items -->
|
||||
<div class="menu-item" @click="navigateTo('/profile')">
|
||||
<span class="material-icons">person</span> {{ t('navigation.profile') }}
|
||||
</div>
|
||||
|
||||
<template v-if="authStore.isAdmin">
|
||||
<div class="menu-item" @click="navigateTo('/admin')">
|
||||
<span class="material-icons">admin_panel_settings</span> {{ t('navigation.admin') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="authStore.isPromoter">
|
||||
<div class="menu-item" @click="navigateTo('/promoter')">
|
||||
<span class="material-icons">store</span> Panel Promotor
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="authStore.isDriver">
|
||||
<div class="menu-item" @click="navigateTo('/driver')">
|
||||
<span class="material-icons">speed</span> Panel Conductor
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="menu-item" @click="navigateTo('/favorites')">
|
||||
<span class="material-icons">favorite</span> {{ t('navigation.favorites') }}
|
||||
</div>
|
||||
|
||||
<div class="menu-divider"></div>
|
||||
|
||||
<div class="menu-divider"></div>
|
||||
|
||||
<!-- Bottom Menu Group -->
|
||||
<div class="menu-bottom-group">
|
||||
<div class="menu-item language-toggle" @click="toggleLanguage">
|
||||
<span class="material-icons">translate</span>
|
||||
{{ locale === 'es' ? 'English (EN)' : 'Español (ES)' }}
|
||||
</div>
|
||||
|
||||
<div class="menu-item theme-toggle-container">
|
||||
<span class="material-icons">palette</span>
|
||||
<span class="toggle-label">Modo Oscuro</span>
|
||||
<ThemeToggle class="theme-switch-btn" />
|
||||
</div>
|
||||
|
||||
<div class="menu-item report-menu-item" @click="openReportModal">
|
||||
<span class="material-icons">report_problem</span> Enviar Reporte
|
||||
</div>
|
||||
|
||||
<div v-if="!authStore.isAuthenticated" class="menu-item login-item" @click="navigateTo('/login')">
|
||||
<span class="material-icons">account_circle</span> Iniciar Sesión
|
||||
</div>
|
||||
<div v-else class="logout-container" @click="handleLogout">
|
||||
<button class="logout-btn-custom">
|
||||
<div class="sign">
|
||||
<svg viewBox="0 0 512 512"><path d="M377.9 105.9L500.7 228.7c7.2 7.2 11.3 17.1 11.3 27.3s-4.1 20.1-11.3 27.3L377.9 406.1c-6.4 6.4-15 9.9-24 9.9c-18.7 0-33.9-15.2-33.9-33.9l0-62.1-128 0c-17.7 0-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32l128 0 0-62.1c0-18.7 15.2-33.9 33.9-33.9c9 0 17.6 3.6 24 9.9zM160 96L96 96c-17.7 0-32 14.3-32 32l0 256c0 17.7 14.3 32 32 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-64 0c-53 0-96-43-96-96L0 128C0 75 43 32 96 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32z"></path></svg>
|
||||
</div>
|
||||
<div class="btn-text">Cerrar Sesión</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<h1 class="header-title" @click="goToHome">{{ t('header.title') }}</h1>
|
||||
|
||||
<div class="header-actions">
|
||||
<!-- Buttons moved to side menu for cleaner UI -->
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ReportModal from './ReportModal.vue'
|
||||
import ThemeToggle from './common/ThemeToggle.vue'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const showMenu = ref(false)
|
||||
const showReportModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
// Load saved language preference
|
||||
const savedLang = localStorage.getItem('user_lang')
|
||||
if (savedLang) {
|
||||
locale.value = savedLang
|
||||
}
|
||||
})
|
||||
|
||||
const toggleLanguage = () => {
|
||||
locale.value = locale.value === 'es' ? 'en' : 'es'
|
||||
localStorage.setItem('user_lang', locale.value)
|
||||
}
|
||||
|
||||
defineEmits<{
|
||||
feedbackClick: [];
|
||||
}>();
|
||||
|
||||
const toggleMenu = () => {
|
||||
showMenu.value = !showMenu.value
|
||||
};
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
router.push(path)
|
||||
showMenu.value = false
|
||||
}
|
||||
|
||||
const goToHome = () => {
|
||||
router.push('/map')
|
||||
}
|
||||
|
||||
const openReportModal = () => {
|
||||
showReportModal.value = true
|
||||
showMenu.value = false
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
showMenu.value = false
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// toggleDarkMode removed as it was unused and causing TS errors
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background-color: var(--header-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: var(--header-text);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
height: 64px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2000;
|
||||
width: 100%;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.app-header.admin-mode {
|
||||
background-color: rgba(30, 41, 59, 0.9);
|
||||
border-bottom: 2px solid var(--active-color);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.driver-badge {
|
||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
||||
color: #101820;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 4px 12px rgba(254, 231, 21, 0.3);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
grid-column: 2;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* En modo oscuro, resaltar un poco más */
|
||||
:global(.dark) .header-title {
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.header-title:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
transition: all 0.2s;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.header-button:hover {
|
||||
background-color: var(--hover-bg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.report-btn-right {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
color: #fbbf24;
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.report-btn-right:hover {
|
||||
background: var(--active-color);
|
||||
color: #101820;
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 4px 15px rgba(254, 231, 21, 0.3);
|
||||
border-color: var(--active-color);
|
||||
}
|
||||
|
||||
.report-text {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.report-text {
|
||||
display: none;
|
||||
}
|
||||
.report-btn-right {
|
||||
padding: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.logout-header-btn {
|
||||
color: #f87171 !important;
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
border: 1px solid rgba(248, 113, 113, 0.15);
|
||||
margin-left: 8px;
|
||||
box-shadow: 0 0 15px rgba(248, 113, 113, 0.05);
|
||||
}
|
||||
|
||||
.logout-header-btn:hover {
|
||||
background: rgba(248, 113, 113, 0.2);
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
box-shadow: 0 4px 15px rgba(248, 113, 113, 0.2);
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
height: 100vh;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: 32px 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 20px 0 50px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
padding: 0 32px 32px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 900;
|
||||
color: var(--active-color);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: var(--hover-bg);
|
||||
padding-left: 48px;
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.menu-item .material-icons {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.menu-item:hover .material-icons {
|
||||
color: var(--active-color);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 24px 32px;
|
||||
}
|
||||
|
||||
.menu-bottom-group {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.report-menu-item {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.report-menu-item:hover {
|
||||
background-color: rgba(251, 191, 36, 0.1);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.report-menu-item .material-icons {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.login-item {
|
||||
color: #4ade80;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 20px 32px calc(20px + var(--safe-area-bottom));
|
||||
background: rgba(74, 222, 128, 0.03);
|
||||
}
|
||||
|
||||
.login-item:hover {
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
color: #22c55e;
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.login-item .material-icons {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.login-item:hover .material-icons {
|
||||
color: #22c55e;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.menu-slide-enter-active,
|
||||
.menu-slide-leave-active {
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.menu-slide-enter-from,
|
||||
.menu-slide-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.overlay-fade-enter-active,
|
||||
.overlay-fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.overlay-fade-enter-from,
|
||||
.overlay-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 24px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.theme-toggle-container {
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.theme-switch-btn {
|
||||
transform: scale(0.9);
|
||||
margin-right: -2px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-header {
|
||||
height: 72px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Menu Button */
|
||||
.menu-btn-custom {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 14px;
|
||||
font-family: 'Inter', Verdana, sans-serif;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.menu-btn-custom:hover {
|
||||
transform: scale(1.1);
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.menu-btn-custom .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Logout Custom Button */
|
||||
.logout-container {
|
||||
padding: 24px 32px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.logout-btn-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition-duration: .3s;
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.2);
|
||||
background-color: var(--active-color);
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
}
|
||||
|
||||
.sign {
|
||||
width: 45px;
|
||||
transition-duration: .3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sign svg {
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.sign svg path {
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
position: absolute;
|
||||
right: 0%;
|
||||
width: 0%;
|
||||
opacity: 0;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
transition-duration: .3s;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
.logout-btn-custom:hover {
|
||||
width: 180px;
|
||||
border-radius: 40px;
|
||||
transition-duration: .3s;
|
||||
}
|
||||
|
||||
.logout-btn-custom:hover .sign {
|
||||
width: 30%;
|
||||
transition-duration: .3s;
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.logout-btn-custom:hover .btn-text {
|
||||
opacity: 1;
|
||||
width: 70%;
|
||||
transition-duration: .3s;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.logout-btn-custom:active {
|
||||
transform: translate(2px ,2px);
|
||||
}
|
||||
</style>
|
||||
148
frontend/src/components/BottomNav.vue
Normal file
148
frontend/src/components/BottomNav.vue
Normal file
@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const navItems = [
|
||||
{ name: 'map', path: '/map', icon: 'map' },
|
||||
{ name: 'schedules', path: '/schedules', icon: 'schedule' },
|
||||
{ name: 'discover', path: '/discover', icon: 'explore' },
|
||||
{ name: 'taxi', path: '/taxi', icon: 'directions_bus' } // Cambiado a ícono de transporte más general
|
||||
]
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return route.path === path
|
||||
}
|
||||
|
||||
// Scroll detection logic
|
||||
const isVisible = ref(true)
|
||||
let lastScrollPosition = 0
|
||||
|
||||
const handleScroll = () => {
|
||||
const currentScrollPosition = window.pageYOffset || document.documentElement.scrollTop
|
||||
if (currentScrollPosition < 0) return // For iOS elastic scroll
|
||||
|
||||
if (Math.abs(currentScrollPosition - lastScrollPosition) < 10) return
|
||||
|
||||
isVisible.value = currentScrollPosition < lastScrollPosition || currentScrollPosition < 50
|
||||
lastScrollPosition = currentScrollPosition
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bottom-nav" :class="{ 'nav-hidden': !isVisible }">
|
||||
<div
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="navigateTo(item.path)"
|
||||
>
|
||||
<span class="material-icons">{{ item.icon }}</span>
|
||||
<span class="nav-label">{{ t('navigation.' + item.name) }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(70px + var(--safe-area-bottom));
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -10px 30px rgba(0,0,0,0.3);
|
||||
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.nav-hidden {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
padding: 8px 12px;
|
||||
border-radius: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--active-color);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 26px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.nav-item.active .material-icons {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.bottom-nav {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: 600px;
|
||||
bottom: 24px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
|
||||
height: 80px;
|
||||
padding: 0 20px;
|
||||
/* En desktop no la ocultamos para mantener la UX de cursor */
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.nav-hidden {
|
||||
transform: translate(-50%, 150%);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
267
frontend/src/components/BusStopEditor.vue
Normal file
267
frontend/src/components/BusStopEditor.vue
Normal file
@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<div class="editor-content">
|
||||
<h2>{{ isEditing ? 'Editar Parada' : 'Crear Parada' }}</h2>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Nombre</label>
|
||||
<input v-model="formData.name" type="text" placeholder="Nombre de la Parada" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tipo</label>
|
||||
<select v-model="formData.stop_type">
|
||||
<option value="regular">Regular</option>
|
||||
<option value="terminal">Terminal</option>
|
||||
<option value="express_only">Solo Expreso</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="formData.has_shelter"> Techo
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="formData.has_seating"> Asientos
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="formData.is_accessible"> Accesible
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="gps-section">
|
||||
<div class="coordinates">
|
||||
Lat: {{ formData.latitude.toFixed(6) }}, Lon: {{ formData.longitude.toFixed(6) }}
|
||||
</div>
|
||||
<button @click="getCurrentLocation" class="gps-button" :disabled="isLoadingGps">
|
||||
<span class="material-icons">my_location</span>
|
||||
{{ isLoadingGps ? 'Localizando...' : 'Usar GPS Preciso' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-wrapper">
|
||||
<div id="editor-map" class="editor-map"></div>
|
||||
<div v-if="!isMapLoaded" class="map-loading">Cargando Mapa...</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="$emit('cancel')" class="cancel-button">Cancelar</button>
|
||||
<button @click="handleSave" class="save-button" :disabled="isSaving">
|
||||
{{ isSaving ? 'Guardando...' : 'Guardar Parada' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Geolocation } from '@capacitor/geolocation'
|
||||
import { useGoogleMaps } from '@/composables/useGoogleMaps'
|
||||
import type { BusStop } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
initialStop?: BusStop | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['save', 'cancel'])
|
||||
|
||||
const isEditing = ref(!!props.initialStop)
|
||||
const isSaving = ref(false)
|
||||
const isLoadingGps = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
latitude: 8.4284, // Default (David)
|
||||
longitude: -82.4309,
|
||||
stop_type: 'regular',
|
||||
has_shelter: false,
|
||||
has_seating: false,
|
||||
is_accessible: false,
|
||||
city: 'David', // Default
|
||||
})
|
||||
|
||||
const { initMap, addMarker, setCenter, isLoaded: isMapLoaded } = useGoogleMaps()
|
||||
let marker: google.maps.Marker | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.initialStop) {
|
||||
formData.value = {
|
||||
name: props.initialStop.name,
|
||||
latitude: props.initialStop.latitude,
|
||||
longitude: props.initialStop.longitude,
|
||||
stop_type: props.initialStop.stop_type,
|
||||
has_shelter: props.initialStop.has_shelter,
|
||||
has_seating: props.initialStop.has_seating,
|
||||
is_accessible: props.initialStop.is_accessible,
|
||||
city: props.initialStop.city || 'David',
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
initMap('editor-map', { lat: formData.value.latitude, lng: formData.value.longitude }, 16)
|
||||
updateMarker()
|
||||
})
|
||||
|
||||
// Watch for map load to add marker if missed
|
||||
watch(isMapLoaded, (loaded) => {
|
||||
if (loaded) {
|
||||
updateMarker()
|
||||
}
|
||||
})
|
||||
|
||||
function updateMarker() {
|
||||
if (!isMapLoaded.value) return
|
||||
|
||||
if (marker) {
|
||||
marker.setPosition({ lat: formData.value.latitude, lng: formData.value.longitude })
|
||||
} else {
|
||||
marker = addMarker(
|
||||
{ lat: formData.value.latitude, lng: formData.value.longitude },
|
||||
{
|
||||
draggable: true,
|
||||
onDragEnd: (pos) => {
|
||||
formData.value.latitude = pos.lat
|
||||
formData.value.longitude = pos.lng
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
setCenter(formData.value.latitude, formData.value.longitude)
|
||||
}
|
||||
|
||||
async function getCurrentLocation() {
|
||||
isLoadingGps.value = true
|
||||
try {
|
||||
const coordinates = await Geolocation.getCurrentPosition({
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000
|
||||
})
|
||||
formData.value.latitude = coordinates.coords.latitude
|
||||
formData.value.longitude = coordinates.coords.longitude
|
||||
updateMarker()
|
||||
} catch (e) {
|
||||
console.error('GPS Error', e)
|
||||
error.value = 'Error al obtener la ubicación GPS. Asegúrate de dar los permisos.'
|
||||
} finally {
|
||||
isLoadingGps.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!formData.value.name) {
|
||||
error.value = 'El nombre es obligatorio'
|
||||
return
|
||||
}
|
||||
|
||||
emit('save', {
|
||||
...formData.value,
|
||||
id: props.initialStop?.id
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
input, select {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gps-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gps-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #2ecc71;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gps-button:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
height: 300px;
|
||||
background: #eee;
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
442
frontend/src/components/BusStopInfoModal.vue
Normal file
442
frontend/src/components/BusStopInfoModal.vue
Normal file
@ -0,0 +1,442 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { BusStop } from '@/types'
|
||||
import { busStopsService } from '@/services/busStopsService'
|
||||
import { favoritesService } from '@/services/favoritesService'
|
||||
import { formatTo12Hour } from '@/utils/timeFormatter'
|
||||
|
||||
interface Props {
|
||||
busStop: BusStop | null
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['close', 'navigate'])
|
||||
|
||||
const upcomingArrivals = ref<{ routeName: string; arrivalTime: string }[]>([])
|
||||
const isLoading = ref(false)
|
||||
const isFavorited = ref(false)
|
||||
const favoriteId = ref<string | null>(null)
|
||||
|
||||
// Function to fetch arrivals
|
||||
async function loadArrivals() {
|
||||
if (props.busStop) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
upcomingArrivals.value = await busStopsService.getNextBusArrivals(props.busStop.id)
|
||||
} catch (e) {
|
||||
console.error('Failed to load arrivals', e)
|
||||
upcomingArrivals.value = []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkFavoriteStatus() {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (!token || !props.busStop) {
|
||||
isFavorited.value = false
|
||||
favoriteId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const favorites = await favoritesService.getMyFavorites()
|
||||
const found = favorites.find(f => f.item_type === 'stop' && f.item_id === props.busStop?.id)
|
||||
isFavorited.value = !!found
|
||||
favoriteId.value = found ? found.id : null
|
||||
} catch (e) {
|
||||
console.error("Error checking favorite status", e)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (!token) {
|
||||
alert("Debes iniciar sesión para guardar favoritos")
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.busStop) return
|
||||
|
||||
try {
|
||||
if (isFavorited.value && props.busStop) {
|
||||
await favoritesService.removeFavorite('stop', props.busStop.id)
|
||||
isFavorited.value = false
|
||||
favoriteId.value = null
|
||||
} else {
|
||||
const fav = await favoritesService.addFavorite('stop', props.busStop.id)
|
||||
isFavorited.value = true
|
||||
favoriteId.value = fav.id
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Error al actualizar favorito")
|
||||
}
|
||||
}
|
||||
|
||||
function startInternalNavigation() {
|
||||
if (props.busStop) {
|
||||
emit('navigate', props.busStop)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes in busStop or isOpen to reload data
|
||||
watch(() => props.busStop, async (newStop) => {
|
||||
if (newStop && props.isOpen) {
|
||||
await loadArrivals()
|
||||
await checkFavoriteStatus()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.isOpen, async (isOpen) => {
|
||||
if (isOpen && props.busStop) {
|
||||
await loadArrivals()
|
||||
await checkFavoriteStatus()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="isOpen" class="modal-overlay" @click="emit('close')">
|
||||
<div class="modal-content" @click.stop>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<div v-if="busStop" class="header-info">
|
||||
<div class="title-row">
|
||||
<h3 class="stop-name">{{ busStop.name }}</h3>
|
||||
<button class="fav-btn" @click="toggleFavorite">
|
||||
<span class="material-icons" :class="{ 'favorited': isFavorited }">
|
||||
{{ isFavorited ? 'favorite' : 'favorite_border' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="busStop.address" class="stop-address">
|
||||
<span class="material-icons text-sm">location_on</span>
|
||||
{{ busStop.address }}
|
||||
</p>
|
||||
</div>
|
||||
<button class="close-btn" @click="emit('close')">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Amenities Chips -->
|
||||
<div v-if="busStop" class="amenities-container">
|
||||
<div v-if="busStop.has_shelter" class="amenity-chip" title="Shelter available">
|
||||
<span class="material-icons md-16">roofing</span>
|
||||
<span>Shelter</span>
|
||||
</div>
|
||||
<div v-if="busStop.has_seating" class="amenity-chip" title="Seating available">
|
||||
<span class="material-icons md-16">event_seat</span>
|
||||
<span>Seating</span>
|
||||
</div>
|
||||
<div v-if="busStop.is_accessible" class="amenity-chip" title="Wheelchair accessible">
|
||||
<span class="material-icons md-16">accessible</span>
|
||||
<span>Accessible</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="modal-body">
|
||||
<h4 class="section-title">Next Arrivals</h4>
|
||||
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<span class="material-icons spin">refresh</span>
|
||||
<p>Loading arrivals...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="upcomingArrivals.length > 0" class="arrivals-list">
|
||||
<div
|
||||
v-for="(arrival, index) in upcomingArrivals"
|
||||
:key="index"
|
||||
class="arrival-item"
|
||||
:style="{ animationDelay: `${index * 0.1}s` }"
|
||||
>
|
||||
<div class="route-info">
|
||||
<span class="material-icons bus-icon">directions_bus</span>
|
||||
<span class="route-name">{{ arrival.routeName }}</span>
|
||||
</div>
|
||||
<div class="arrival-time">
|
||||
{{ formatTo12Hour(arrival.arrivalTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>No upcoming arrivals found.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer / Actions -->
|
||||
<div class="modal-footer">
|
||||
<button class="action-btn secondary" @click="startInternalNavigation">
|
||||
<span class="material-icons md-18">navigation</span>
|
||||
Navigate
|
||||
</button>
|
||||
<button class="action-btn primary" @click="loadArrivals">
|
||||
<span class="material-icons md-18">refresh</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end; /* Bottom sheet style on mobile, centered on desktop ideally */
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* On larger screens, center it */
|
||||
@media (min-width: 768px) {
|
||||
.modal-overlay {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--card-bg);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 24px;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
|
||||
animation: slide-up 0.3s ease-out;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.modal-content {
|
||||
border-radius: 16px;
|
||||
animation: zoom-in 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stop-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stop-address {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fav-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
transition: transform 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.fav-btn:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.fav-btn .material-icons.favorited {
|
||||
color: #ff4757;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.arrivals-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.arrival-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.route-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bus-icon {
|
||||
color: var(--header-bg);
|
||||
}
|
||||
|
||||
.route-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.arrival-time {
|
||||
font-weight: 700;
|
||||
color: var(--active-color, green);
|
||||
}
|
||||
|
||||
/* Amenities Styles */
|
||||
.amenities-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.amenity-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.md-16 { font-size: 16px; }
|
||||
.md-18 { font-size: 18px; }
|
||||
|
||||
/* Modal Footer */
|
||||
.modal-footer {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between; /* Spread buttons */
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background-color: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
flex: 1; /* Take remaining space */
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.arrival-item {
|
||||
animation: fade-in-slide 0.4s ease-out forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
@keyframes fade-in-slide {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes zoom-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||||
</style>
|
||||
140
frontend/src/components/FavoriteButton.vue
Normal file
140
frontend/src/components/FavoriteButton.vue
Normal file
@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<button
|
||||
class="favorite-btn"
|
||||
:class="{ 'is-favorite': isFavorited, 'is-loading': isLoading }"
|
||||
@click.stop="handleToggle"
|
||||
:title="isFavorited ? 'Quitar de favoritos' : 'Agregar a favoritos'"
|
||||
>
|
||||
<span class="material-icons heart-icon">
|
||||
{{ isFavorited ? 'favorite' : 'favorite_border' }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useFavoritesStore } from '@/stores/favorites'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
itemType: 'coupon' | 'business' | 'taxi' | 'route'
|
||||
itemId: string
|
||||
itemName?: string
|
||||
itemImage?: string
|
||||
}>()
|
||||
|
||||
const favoritesStore = useFavoritesStore()
|
||||
const authStore = useAuthStore()
|
||||
const isLoading = ref(false)
|
||||
|
||||
const isFavorited = computed(() => {
|
||||
return favoritesStore.isFavorite(props.itemType, props.itemId)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Load favorites if authenticated and not loaded yet
|
||||
if (authStore.isAuthenticated && favoritesStore.favorites.length === 0) {
|
||||
favoritesStore.loadFavorites()
|
||||
}
|
||||
})
|
||||
|
||||
async function handleToggle() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
// Optionally redirect to login or show message
|
||||
alert('Debes iniciar sesión para agregar favoritos')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
await favoritesStore.toggleFavorite(
|
||||
props.itemType,
|
||||
props.itemId,
|
||||
props.itemName,
|
||||
props.itemImage
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.favorite-btn {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.favorite-btn:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.favorite-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.heart-icon {
|
||||
color: #e91e63;
|
||||
font-size: 22px;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.favorite-btn.is-favorite .heart-icon {
|
||||
animation: heartBeat 0.5s ease;
|
||||
}
|
||||
|
||||
.favorite-btn.is-loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.favorite-btn.is-loading .heart-icon {
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes heartBeat {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
.dark .favorite-btn {
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
}
|
||||
</style>
|
||||
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
285
frontend/src/components/ReportModal.vue
Normal file
285
frontend/src/components/ReportModal.vue
Normal file
@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="isOpen" class="modal-overlay" @click.self="close">
|
||||
<div class="modal-container glass-effect">
|
||||
<div class="modal-header">
|
||||
<div class="title-with-icon">
|
||||
<span class="material-icons report-icon">report_problem</span>
|
||||
<h2>Enviar Reporte</h2>
|
||||
</div>
|
||||
<button class="close-btn" @click="close">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p class="instruction">Cuéntanos qué sucede o envíanos una sugerencia. El equipo administrativo revisará tu mensaje.</p>
|
||||
|
||||
<textarea
|
||||
v-model="message"
|
||||
placeholder="Escribe tu mensaje aquí..."
|
||||
class="report-textarea"
|
||||
:disabled="isSending"
|
||||
></textarea>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="success" class="success-message">
|
||||
¡Reporte enviado con éxito! Gracias por tu colaboración.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="cancel-btn" @click="close" :disabled="isSending">Cancelar</button>
|
||||
<button
|
||||
class="send-btn"
|
||||
@click="handleSend"
|
||||
:disabled="isSending || !message.trim() || success"
|
||||
>
|
||||
<span v-if="isSending" class="spinner-small"></span>
|
||||
<span v-else>Enviar Reporte</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { reportsService } from '@/services/reportsService'
|
||||
|
||||
defineProps<{
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const message = ref('')
|
||||
const isSending = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref(false)
|
||||
|
||||
function close() {
|
||||
if (isSending.value) return
|
||||
emit('close')
|
||||
// Reset state after transition
|
||||
setTimeout(() => {
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
success.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!message.value.trim()) return
|
||||
|
||||
isSending.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await reportsService.sendReport(message.value)
|
||||
success.value = true
|
||||
setTimeout(() => {
|
||||
close()
|
||||
}, 2000)
|
||||
} catch (e) {
|
||||
error.value = 'Hubo un error al enviar el reporte. Por favor, intenta de nuevo.'
|
||||
} finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 28px;
|
||||
padding: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title-with-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.report-icon {
|
||||
color: var(--active-color);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #ef4444;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.instruction {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.report-textarea {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
resize: none;
|
||||
transition: all 0.3s;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.report-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--active-color);
|
||||
background: rgba(254, 231, 21, 0.05);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: #22c55e;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 12px 24px;
|
||||
border-radius: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: var(--active-color);
|
||||
color: #101820;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(254, 231, 21, 0.3);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(16, 24, 32, 0.2);
|
||||
border-top-color: #101820;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-fade-enter-active .modal-container {
|
||||
animation: modal-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from { transform: scale(0.9) translateY(20px); opacity: 0; }
|
||||
to { transform: scale(1) translateY(0); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
207
frontend/src/components/auth/LoginForm.vue
Normal file
207
frontend/src/components/auth/LoginForm.vue
Normal file
@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authService } from '@/services/authService'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
defineProps<{
|
||||
onToggle: () => void
|
||||
}>()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const keepSession = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const handleLogin = async () => {
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await authService.login({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
keep_session: keepSession.value
|
||||
})
|
||||
|
||||
authStore.login(response.access_token, response.role, response.full_name)
|
||||
|
||||
// Redirect based on role or home
|
||||
const role = response.role.toUpperCase()
|
||||
if (role === 'ADMIN') {
|
||||
router.push('/admin')
|
||||
} else if (role === 'DRIVER') {
|
||||
router.push('/driver')
|
||||
} else if (role === 'PROMOTER') {
|
||||
router.push('/promoter')
|
||||
} else {
|
||||
router.push('/map')
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!error.response) {
|
||||
errorMessage.value = `Error de conexión: No se pudo contactar con el servidor en ${authService.getApiUrl()}. Revisa si el backend está encendido y accesible desde este dispositivo.`
|
||||
} else if (error.response.status === 401) {
|
||||
errorMessage.value = 'Correo o contraseña incorrectos.'
|
||||
} else {
|
||||
errorMessage.value = error.response?.data?.detail || 'Error interno del servidor. Inténtalo más tarde.'
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-form">
|
||||
<h2 class="auth-title">Iniciar Sesión</h2>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="form-container">
|
||||
<div class="form-group">
|
||||
<label for="email">Correo Electrónico</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
v-model="email"
|
||||
placeholder="ejemplo@correo.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
v-model="password"
|
||||
placeholder="********"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-options">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" v-model="keepSession" />
|
||||
<span class="checkmark"></span>
|
||||
Mantener sesión
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="error-text">{{ errorMessage }}</p>
|
||||
|
||||
<button type="submit" class="auth-button" :disabled="isLoading">
|
||||
<span v-if="isLoading">Cargando...</span>
|
||||
<span v-else>Entrar</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>¿No tienes cuenta? <a @click.prevent="onToggle" href="#">Regístrate aquí</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
input[type="email"],
|
||||
input[type="password"] {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.form-options {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.auth-button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.auth-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef5350;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
255
frontend/src/components/auth/RegisterForm.vue
Normal file
255
frontend/src/components/auth/RegisterForm.vue
Normal file
@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { authService } from '@/services/authService'
|
||||
|
||||
const { onToggle, onSuccess } = defineProps<{
|
||||
onToggle: () => void,
|
||||
onSuccess: () => void
|
||||
}>()
|
||||
|
||||
// Form data
|
||||
const fullName = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const handleRegister = async () => {
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await authService.registerPassenger({
|
||||
full_name: fullName.value,
|
||||
email: email.value,
|
||||
password: password.value
|
||||
})
|
||||
|
||||
successMessage.value = 'Registro exitoso. Ya puedes iniciar sesión.'
|
||||
setTimeout(() => {
|
||||
onSuccess() // Back to login
|
||||
}, 2000)
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.response?.data?.detail || 'Error al registrarse'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="register-form">
|
||||
<h2 class="auth-title">Registrarse</h2>
|
||||
|
||||
<div class="form-scroll-container">
|
||||
<form @submit.prevent="handleRegister" class="form-container">
|
||||
<!-- Common Fields -->
|
||||
<div class="form-group">
|
||||
<label>Nombre Completo</label>
|
||||
<input type="text" v-model="fullName" placeholder="Juan Pérez" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Correo Electrónico</label>
|
||||
<input type="email" v-model="email" placeholder="juan@correo.com" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Contraseña</label>
|
||||
<input type="password" v-model="password" placeholder="********" required />
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="error-text">{{ errorMessage }}</p>
|
||||
<p v-if="successMessage" class="success-text">{{ successMessage }}</p>
|
||||
|
||||
<button type="submit" class="auth-button" :disabled="isLoading">
|
||||
<span v-if="isLoading">Cargando...</span>
|
||||
<span v-else>Registrarse</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>¿Ya tienes cuenta? <a @click.prevent="onToggle" href="#">Inicia sesión</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.register-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.role-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.selection-detail {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.role-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.role-card:hover {
|
||||
border-color: var(--accent-color);
|
||||
transform: translateY(-4px);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.role-card .material-icons {
|
||||
font-size: 32px;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role-card h3 {
|
||||
font-size: 16px;
|
||||
margin: 4px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.role-card p {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-scroll-container {
|
||||
max-height: 450px;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
.form-scroll-container::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.form-scroll-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vehicle-tabs {
|
||||
display: flex;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.vehicle-tabs button {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.vehicle-tabs button.active {
|
||||
background: var(--card-bg);
|
||||
color: var(--accent-color);
|
||||
box-shadow: 0 2px 4px var(--shadow);
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error-text { color: #ef5350; font-size: 14px; }
|
||||
.success-text { color: #4caf50; font-size: 14px; font-weight: 600; }
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
156
frontend/src/components/common/OffersBadge.vue
Normal file
156
frontend/src/components/common/OffersBadge.vue
Normal file
@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isClose?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="offers-container" :class="{ 'is-close': isClose }">
|
||||
<div class="loader">
|
||||
<svg width="100" height="100" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<mask id="clipping">
|
||||
<polygon points="0,0 100,0 100,100 0,100" fill="black"></polygon>
|
||||
<polygon points="25,25 75,25 50,75" fill="white"></polygon>
|
||||
<polygon points="50,25 75,75 25,75" fill="white"></polygon>
|
||||
<polygon points="35,35 65,35 50,65" fill="white"></polygon>
|
||||
<polygon points="35,35 65,35 50,65" fill="white"></polygon>
|
||||
</mask>
|
||||
</defs>
|
||||
</svg>
|
||||
<div class="box">
|
||||
<span class="material-icons offer-icon">{{ isClose ? 'close' : 'local_offer' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.offers-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
transform: scale(0.65);
|
||||
}
|
||||
|
||||
.loader {
|
||||
/* Default: SIBU GOLD */
|
||||
--color-one: #fee715;
|
||||
--color-two: #facc15;
|
||||
--color-three: rgba(254, 231, 21, 0.5);
|
||||
--color-four: rgba(250, 204, 21, 0.3);
|
||||
--color-five: rgba(254, 231, 21, 0.1);
|
||||
--time-animation: 2s;
|
||||
--size: 1;
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
transform: scale(var(--size));
|
||||
box-shadow: 0 0 25px 0 var(--color-three),
|
||||
0 10px 30px 0 var(--color-four);
|
||||
animation: colorize calc(var(--time-animation) * 3) ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* RED PHASE: CLOSE MODE */
|
||||
.is-close .loader {
|
||||
--color-one: #ef4444;
|
||||
--color-two: #dc2626;
|
||||
--color-three: rgba(239, 68, 68, 0.5);
|
||||
--color-four: rgba(220, 38, 38, 0.3);
|
||||
--color-five: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.loader::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
border-top: solid 2px var(--color-one);
|
||||
border-bottom: solid 2px var(--color-two);
|
||||
background: radial-gradient(circle, var(--color-five), transparent);
|
||||
box-shadow: inset 0 10px 20px 0 var(--color-three),
|
||||
inset 0 -10px 20px 0 var(--color-four);
|
||||
}
|
||||
|
||||
.loader .box {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: var(--color-one);
|
||||
mask: url(#clipping);
|
||||
-webkit-mask: url(#clipping);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.offer-icon {
|
||||
color: #101820;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
z-index: 5;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
|
||||
}
|
||||
|
||||
.is-close .offer-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.loader svg {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.loader svg #clipping {
|
||||
filter: contrast(15);
|
||||
animation: roundness calc(var(--time-animation) / 2) linear infinite;
|
||||
}
|
||||
|
||||
.loader svg #clipping polygon {
|
||||
filter: blur(7px);
|
||||
}
|
||||
|
||||
.loader svg #clipping polygon:nth-child(1) {
|
||||
transform-origin: 75% 25%;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.loader svg #clipping polygon:nth-child(2) {
|
||||
transform-origin: 50% 50%;
|
||||
animation: rotation var(--time-animation) linear infinite reverse;
|
||||
}
|
||||
|
||||
.loader svg #clipping polygon:nth-child(3) {
|
||||
transform-origin: 50% 60%;
|
||||
animation: rotation var(--time-animation) linear infinite;
|
||||
animation-delay: calc(var(--time-animation) / -3);
|
||||
}
|
||||
|
||||
.loader svg #clipping polygon:nth-child(4) {
|
||||
transform-origin: 40% 40%;
|
||||
animation: rotation var(--time-animation) linear infinite reverse;
|
||||
}
|
||||
|
||||
.loader svg #clipping polygon:nth-child(5) {
|
||||
transform-origin: 60% 40%;
|
||||
animation: rotation var(--time-animation) linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes roundness {
|
||||
0%, 60%, 100% { filter: contrast(12); }
|
||||
20%, 40% { filter: contrast(2); }
|
||||
}
|
||||
|
||||
@keyframes colorize {
|
||||
0%, 100% { filter: saturate(1) brightness(1); }
|
||||
50% { filter: saturate(1.5) brightness(1.2); }
|
||||
}
|
||||
</style>
|
||||
233
frontend/src/components/common/ThemeToggle.vue
Normal file
233
frontend/src/components/common/ThemeToggle.vue
Normal file
@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="theme-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="theme-switch__checkbox"
|
||||
:checked="themeStore.isDarkMode"
|
||||
@change="themeStore.toggleDarkMode()"
|
||||
>
|
||||
<div class="theme-switch__container">
|
||||
<div class="theme-switch__clouds"></div>
|
||||
<div class="theme-switch__stars-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 55" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M135.831 3.00688C135.055 3.85027 134.111 4.29946 133 4.35447C134.111 4.40947 135.055 4.85867 135.831 5.71123C136.607 6.55462 136.996 7.56303 136.996 8.72727C136.996 7.95722 137.172 7.25134 137.525 6.59129C137.886 5.93124 138.372 5.39954 138.98 5.00535C139.598 4.60199 140.268 4.39114 141 4.35447C139.88 4.2903 138.936 3.85027 138.16 3.00688C137.384 2.16348 136.996 1.16425 136.996 0C136.996 1.16425 136.607 2.16348 135.831 3.00688ZM31 23.3545C32.1114 23.2995 33.0551 22.8503 33.8313 22.0069C34.6075 21.1635 34.9956 20.1642 34.9956 19C34.9956 20.1642 35.3837 21.1635 36.1599 22.0069C36.9361 22.8503 37.8798 23.2903 39 23.3545C38.2679 23.3911 37.5976 23.602 36.9802 24.0053C36.3716 24.3995 35.8864 24.9312 35.5248 25.5913C35.172 26.2513 34.9956 26.9572 34.9956 27.7273C34.9956 26.563 34.6075 25.5546 33.8313 24.7112C33.0551 23.8587 32.1114 23.4095 31 23.3545ZM0 36.3545C1.11136 36.2995 2.05513 35.8503 2.83131 35.0069C3.6075 34.1635 3.99559 33.1642 3.99559 32C3.99559 33.1642 4.38368 34.1635 5.15987 35.0069C5.93605 35.8503 6.87982 36.2903 8 36.3545C7.26792 36.3911 6.59757 36.602 5.98015 37.0053C5.37155 37.3995 4.88644 37.9312 4.52481 38.5913C4.172 39.2513 3.99559 39.9572 3.99559 40.7273C3.99559 39.563 3.6075 38.5546 2.83131 37.7112C2.05513 36.8587 1.11136 36.4095 0 36.3545ZM56.8313 24.0069C56.0551 24.8503 55.1114 25.2995 54 25.3545C55.1114 25.4095 56.0551 25.8587 56.8313 26.7112C57.6075 27.5546 57.9956 28.563 57.9956 29.7273C57.9956 28.9572 58.172 28.2513 58.5248 27.5913C58.8864 26.9312 59.3716 26.3995 59.9802 26.0053C60.5976 25.602 61.2679 25.3911 62 25.3545C60.8798 25.2903 59.9361 24.8503 59.1599 24.0069C58.3837 23.1635 57.9956 22.1642 57.9956 21C57.9956 22.1642 57.6075 23.1635 56.8313 24.0069ZM81 25.3545C82.1114 25.2995 83.0551 24.8503 83.8313 24.0069C84.6075 23.1635 84.9956 22.1642 84.9956 21C84.9956 22.1642 85.3837 23.1635 86.1599 24.0069C86.9361 24.8503 87.8798 25.2903 89 25.3545C88.2679 25.3911 87.5976 25.602 86.9802 26.0053C86.3716 26.3995 85.8864 26.9312 85.5248 27.5913C85.172 28.2513 84.9956 28.9572 84.9956 29.7273C84.9956 28.563 84.6075 27.5546 83.8313 26.7112C83.0551 25.8587 82.1114 25.4095 81 25.3545ZM136 36.3545C137.111 36.2995 138.055 35.8503 138.831 35.0069C139.607 34.1635 139.996 33.1642 139.996 32C139.996 33.1642 140.384 34.1635 141.16 35.0069C141.936 35.8503 142.88 36.2903 144 36.3545C143.268 36.3911 142.598 36.602 141.98 37.0053C141.372 37.3995 140.886 37.9312 140.525 38.5913C140.172 39.2513 139.996 39.9572 139.996 40.7273C139.996 39.563 139.607 38.5546 138.831 37.7112C138.055 36.8587 137.111 36.4095 136 36.3545ZM101.831 49.0069C101.055 49.8503 100.111 50.2995 99 50.3545C100.111 50.4095 101.055 50.8587 101.831 51.7112C102.607 52.5546 102.996 53.563 102.996 54.7273C102.996 53.9572 103.172 53.2513 103.525 52.5913C103.886 51.9312 104.372 51.3995 104.98 51.0053C105.598 50.602 106.268 50.3911 107 50.3545C105.88 50.2903 104.936 49.8503 104.16 49.0069C103.384 48.1635 102.996 47.1642 102.996 46C102.996 47.1642 102.607 48.1635 101.831 49.0069Z" fill="currentColor"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="theme-switch__circle-container">
|
||||
<div class="theme-switch__sun-moon-container">
|
||||
<div class="theme-switch__moon">
|
||||
<div class="theme-switch__spot"></div>
|
||||
<div class="theme-switch__spot"></div>
|
||||
<div class="theme-switch__spot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-switch {
|
||||
--toggle-size: 10px; /* Ajustado para caber mejor en la UI */
|
||||
--container-width: 5.625em;
|
||||
--container-height: 2.5em;
|
||||
--container-radius: 6.25em;
|
||||
--container-light-bg: #3D7EAE;
|
||||
--container-night-bg: #1D1F2C;
|
||||
--circle-container-diameter: 3.375em;
|
||||
--sun-moon-diameter: 2.125em;
|
||||
--sun-bg: #ECCA2F;
|
||||
--moon-bg: #C4C9D1;
|
||||
--spot-color: #959DB1;
|
||||
--circle-container-offset: calc((var(--circle-container-diameter) - var(--container-height)) / 2 * -1);
|
||||
--stars-color: #fff;
|
||||
--clouds-color: #F3FDFF;
|
||||
--back-clouds-color: #AACADF;
|
||||
--transition: .5s cubic-bezier(0, -0.02, 0.4, 1.25);
|
||||
--circle-transition: .3s cubic-bezier(0, -0.02, 0.35, 1.17);
|
||||
}
|
||||
|
||||
.theme-switch, .theme-switch *, .theme-switch *::before, .theme-switch *::after {
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: var(--toggle-size);
|
||||
}
|
||||
|
||||
.theme-switch__container {
|
||||
width: var(--container-width);
|
||||
height: var(--container-height);
|
||||
background-color: var(--container-light-bg);
|
||||
border-radius: var(--container-radius);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
-webkit-box-shadow: 0em -0.062em 0.062em rgba(0, 0, 0, 0.25), 0em 0.062em 0.125em rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0em -0.062em 0.062em rgba(0, 0, 0, 0.25), 0em 0.062em 0.125em rgba(255, 255, 255, 0.94);
|
||||
-webkit-transition: var(--transition);
|
||||
-o-transition: var(--transition);
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-switch__container::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
-webkit-box-shadow: 0em 0.05em 0.187em rgba(0, 0, 0, 0.25) inset, 0em 0.05em 0.187em rgba(0, 0, 0, 0.25) inset;
|
||||
box-shadow: 0em 0.05em 0.187em rgba(0, 0, 0, 0.25) inset, 0em 0.05em 0.187em rgba(0, 0, 0, 0.25) inset;
|
||||
border-radius: var(--container-radius)
|
||||
}
|
||||
|
||||
.theme-switch__checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-switch__circle-container {
|
||||
width: var(--circle-container-diameter);
|
||||
height: var(--circle-container-diameter);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
position: absolute;
|
||||
left: var(--circle-container-offset);
|
||||
top: var(--circle-container-offset);
|
||||
border-radius: var(--container-radius);
|
||||
-webkit-box-shadow: inset 0 0 0 3.375em rgba(255, 255, 255, 0.1), inset 0 0 0 3.375em rgba(255, 255, 255, 0.1), 0 0 0 0.625em rgba(255, 255, 255, 0.1), 0 0 0 1.25em rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 0 0 3.375em rgba(255, 255, 255, 0.1), inset 0 0 0 3.375em rgba(255, 255, 255, 0.1), 0 0 0 0.625em rgba(255, 255, 255, 0.1), 0 0 0 1.25em rgba(255, 255, 255, 0.1);
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-transition: var(--circle-transition);
|
||||
-o-transition: var(--circle-transition);
|
||||
transition: var(--circle-transition);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.theme-switch__sun-moon-container {
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: var(--sun-moon-diameter);
|
||||
height: var(--sun-moon-diameter);
|
||||
margin: auto;
|
||||
border-radius: var(--container-radius);
|
||||
background-color: var(--sun-bg);
|
||||
-webkit-box-shadow: 0.062em 0.062em 0.062em 0em rgba(254, 255, 239, 0.61) inset, 0em -0.062em 0.062em 0em #a1872a inset;
|
||||
box-shadow: 0.062em 0.062em 0.062em 0em rgba(254, 255, 239, 0.61) inset, 0em -0.062em 0.062em 0em #a1872a inset;
|
||||
-webkit-filter: drop-shadow(0.062em 0.125em 0.125em rgba(0, 0, 0, 0.25)) drop-shadow(0em 0.062em 0.125em rgba(0, 0, 0, 0.25));
|
||||
filter: drop-shadow(0.062em 0.125em 0.125em rgba(0, 0, 0, 0.25)) drop-shadow(0em 0.062em 0.125em rgba(0, 0, 0, 0.25));
|
||||
overflow: hidden;
|
||||
-webkit-transition: var(--transition);
|
||||
-o-transition: var(--transition);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.theme-switch__moon {
|
||||
-webkit-transform: translateX(100%);
|
||||
-ms-transform: translateX(100%);
|
||||
transform: translateX(100%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--moon-bg);
|
||||
border-radius: inherit;
|
||||
-webkit-box-shadow: 0.062em 0.062em 0.062em 0em rgba(254, 255, 239, 0.61) inset, 0em -0.062em 0.062em 0em #969696 inset;
|
||||
box-shadow: 0.062em 0.062em 0.062em 0em rgba(254, 255, 239, 0.61) inset, 0em -0.062em 0.062em 0em #969696 inset;
|
||||
-webkit-transition: var(--transition);
|
||||
-o-transition: var(--transition);
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-switch__spot {
|
||||
position: absolute;
|
||||
top: 0.75em;
|
||||
left: 0.312em;
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
border-radius: var(--container-radius);
|
||||
background-color: var(--spot-color);
|
||||
-webkit-box-shadow: 0em 0.0312em 0.062em rgba(0, 0, 0, 0.25) inset;
|
||||
box-shadow: 0em 0.0312em 0.062em rgba(0, 0, 0, 0.25) inset;
|
||||
}
|
||||
|
||||
.theme-switch__spot:nth-of-type(2) {
|
||||
width: 0.375em;
|
||||
height: 0.375em;
|
||||
top: 0.937em;
|
||||
left: 1.375em;
|
||||
}
|
||||
|
||||
.theme-switch__spot:nth-last-of-type(3) {
|
||||
width: 0.25em;
|
||||
height: 0.25em;
|
||||
top: 0.312em;
|
||||
left: 0.812em;
|
||||
}
|
||||
|
||||
.theme-switch__clouds {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
background-color: var(--clouds-color);
|
||||
border-radius: var(--container-radius);
|
||||
position: absolute;
|
||||
bottom: -0.625em;
|
||||
left: 0.312em;
|
||||
-webkit-box-shadow: 0.937em 0.312em var(--clouds-color), -0.312em -0.312em var(--back-clouds-color), 1.437em 0.375em var(--clouds-color), 0.5em -0.125em var(--back-clouds-color), 2.187em 0 var(--clouds-color), 1.25em -0.062em var(--back-clouds-color), 2.937em 0.312em var(--clouds-color), 2em -0.312em var(--back-clouds-color), 3.625em -0.062em var(--clouds-color), 2.625em 0em var(--back-clouds-color), 4.5em -0.312em var(--clouds-color), 3.375em -0.437em var(--back-clouds-color), 4.625em -1.75em 0 0.437em var(--clouds-color), 4em -0.625em var(--back-clouds-color), 4.125em -2.125em 0 0.437em var(--clouds-color);
|
||||
box-shadow: 0.937em 0.312em var(--clouds-color), -0.312em -0.312em var(--back-clouds-color), 1.437em 0.375em var(--clouds-color), 0.5em -0.125em var(--back-clouds-color), 2.187em 0 var(--clouds-color), 1.25em -0.062em var(--back-clouds-color), 2.937em 0.312em var(--clouds-color), 2em -0.312em var(--back-clouds-color), 3.625em -0.062em var(--clouds-color), 2.625em 0em var(--back-clouds-color), 4.5em -0.312em var(--clouds-color), 3.375em -0.437em var(--back-clouds-color), 4.625em -1.75em 0 0.437em var(--clouds-color), 4em -0.625em var(--back-clouds-color), 4.125em -2.125em 0 0.437em var(--clouds-color);
|
||||
-webkit-transition: 0.5s cubic-bezier(0, -0.02, 0.4, 1.25);
|
||||
-o-transition: 0.5s cubic-bezier(0, -0.02, 0.4, 1.25);
|
||||
transition: 0.5s cubic-bezier(0, -0.02, 0.4, 1.25);
|
||||
}
|
||||
|
||||
.theme-switch__stars-container {
|
||||
position: absolute;
|
||||
color: var(--stars-color);
|
||||
top: -100%;
|
||||
left: 0.312em;
|
||||
width: 2.75em;
|
||||
height: auto;
|
||||
-webkit-transition: var(--transition);
|
||||
-o-transition: var(--transition);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
/* Acciones al marcar el checkbox */
|
||||
.theme-switch__checkbox:checked + .theme-switch__container {
|
||||
background-color: var(--container-night-bg);
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__circle-container {
|
||||
left: calc(100% - var(--circle-container-offset) - var(--circle-container-diameter));
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__circle-container:hover {
|
||||
left: calc(100% - var(--circle-container-offset) - var(--circle-container-diameter) - 0.187em)
|
||||
}
|
||||
|
||||
.theme-switch__circle-container:hover {
|
||||
left: calc(var(--circle-container-offset) + 0.187em);
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__moon {
|
||||
-webkit-transform: translate(0);
|
||||
-ms-transform: translate(0);
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__clouds {
|
||||
bottom: -4.062em;
|
||||
}
|
||||
|
||||
.theme-switch__checkbox:checked + .theme-switch__container .theme-switch__stars-container {
|
||||
top: 50%;
|
||||
-webkit-transform: translateY(-50%);
|
||||
-ms-transform: translateY(-50%);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
</style>
|
||||
67
frontend/src/components/common/UserSonar.vue
Normal file
67
frontend/src/components/common/UserSonar.vue
Normal file
@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="sonar-wrapper">
|
||||
<div class="sonar-ring pointer"></div>
|
||||
<div class="sonar-ring ring-1"></div>
|
||||
<div class="sonar-ring ring-2"></div>
|
||||
<div class="sonar-core"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sonar-wrapper {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sonar-core {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #00d4ff; /* Celeste Cian */
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px #00d4ff, 0 0 20px #00d4ff;
|
||||
z-index: 2;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.sonar-ring {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 212, 255, 0.4);
|
||||
animation: pulse 2s infinite ease-out;
|
||||
}
|
||||
|
||||
.ring-1 {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.ring-2 {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.pointer {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
box-shadow: 0 0 15px #00d4ff;
|
||||
animation: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(3);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
47
frontend/src/components/layouts/MainLayout.vue
Normal file
47
frontend/src/components/layouts/MainLayout.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import AppHeader from "../AppHeader.vue";
|
||||
import BottomNav from "../BottomNav.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main-layout">
|
||||
<AppHeader />
|
||||
<main class="main-content" :class="{ 'has-bottom-nav': authStore.isPassenger }">
|
||||
<slot />
|
||||
</main>
|
||||
<BottomNav v-if="authStore.isPassenger" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent; /* Permitir ver fondos de páginas */
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: transparent; /* Permitir ver fondos de páginas */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.has-bottom-nav {
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.has-bottom-nav {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user