Initial commit: SIBU 2.0 MISSION

This commit is contained in:
2026-02-21 09:53:31 -05:00
commit 0c7aa53c8b
400 changed files with 67708 additions and 0 deletions

160
frontend/src/App.vue Normal file
View File

@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { RouterView, useRoute } from "vue-router";
import { useI18n } from 'vue-i18n'
import MainLayout from "./components/layouts/MainLayout.vue";
import { useThemeStore } from './stores/theme'
import { analyticsService } from '@/services/analyticsService'
// Initialize theme store
const route = useRoute()
const { locale } = useI18n()
const themeStore = useThemeStore()
const isSplashScreen = computed(() => route.name === 'splash')
const isAuthScreen = computed(() => route.name === 'auth' || route.path === '/login')
onMounted(() => {
themeStore.applyTheme()
analyticsService.logEvent({
event_name: 'app_open',
properties: { language: locale.value }
})
})
</script>
<template>
<MainLayout v-if="!isSplashScreen && !isAuthScreen">
<RouterView />
</MainLayout>
<RouterView v-else />
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Common Variables */
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--transition-speed: 0.3s;
}
/* DARK THEME (Default & .dark) */
:root,
html.dark {
--bg-primary: #0f172a;
--bg-secondary: #020617;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--border-color: rgba(255, 255, 255, 0.12);
--shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.6);
--header-bg: rgba(15, 23, 42, 0.9);
--header-text: #ffffff;
--card-bg: rgba(30, 41, 59, 0.85); /* Increased opacity for better legibility */
--hover-bg: rgba(255, 255, 255, 0.08);
--active-bg: rgba(254, 231, 21, 0.15);
--active-color: #fee715;
--accent-color: #fee715;
--accent-hover: #fde047;
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.15);
}
/* LIGHT THEME */
html.light-theme {
--bg-primary: #f1f5f9; /* Slightly darker light background */
--bg-secondary: #ffffff;
--text-primary: #0f172a;
--text-secondary: #475569;
--border-color: #cbd5e1; /* More visible borders */
--header-bg: #ffffff;
--header-text: #0f172a;
--card-bg: #ffffff;
--hover-bg: #f1f5f9;
--glass-bg: rgba(255, 255, 255, 0.9);
--glass-border: #e2e8f0;
--shadow: 0 8px 30px rgba(0, 0, 0, 0.12); /* Stronger shadow in light mode */
--active-bg: rgba(16, 24, 32, 0.1);
--active-color: #101820;
--accent-color: #101820;
}
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
background-color: var(--bg-primary);
color: var(--text-primary);
transition: all 0.3s ease;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
letter-spacing: -0.01em;
}
body {
-webkit-font-smoothing: antialiased;
}
#app {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
padding-top: var(--safe-area-top);
}
/* Global Utilities */
.glass-effect {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
}
.gradient-text {
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* App Transition */
.page-enter-active,
.page-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.page-enter-from {
opacity: 0;
transform: translateY(10px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-10px);
}
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--active-color);
}
</style>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -0,0 +1,387 @@
/** Composable for Google Maps integration */
import { ref, onMounted } from 'vue'
import { setOptions, importLibrary } from '@googlemaps/js-api-loader'
const getApiKey = () => import.meta.env.VITE_GOOGLE_MAPS_API_KEY || ''
let mapsLoaded = false
// Global overlay tracker - persists across all composable instances
const globalOverlays = new Map<google.maps.Map, Set<google.maps.Marker | google.maps.Polyline>>()
export function useGoogleMaps() {
const map = ref<google.maps.Map | null>(null)
const isLoaded = ref(false)
const error = ref<string | null>(null)
// Escuchar errores globales de autenticación de Google
if (typeof window !== 'undefined') {
(window as any).gm_auth_failure = () => {
error.value = '⚠️ Error de Autenticación de Google: Revisa que la API de Mapas esté activada y que la facturación de Google Cloud sea válida.';
console.error('❌ Google Maps Auth Failure detected');
};
}
async function loadMaps() {
if (mapsLoaded) {
isLoaded.value = true
error.value = null
return
}
const apiKey = getApiKey()
if (!apiKey || apiKey.length < 10) {
error.value = '❌ Error: VITE_GOOGLE_MAPS_API_KEY no detectada o es inválida.'
console.error(error.value)
return
}
console.log('🌐 Usando Nueva API Funcional de Google Maps...');
try {
// Configuramos las opciones globales como pide el error
setOptions({
key: apiKey,
v: 'weekly'
});
// Cargamos las librerías necesarias una por una
console.log('🛰️ Cargando librerías...');
await importLibrary('maps');
await importLibrary('places');
await importLibrary('geometry');
if (typeof google === 'undefined' || !google.maps) {
throw new Error('Google Maps se cargó pero el espacio de nombres "google.maps" no está disponible.');
}
mapsLoaded = true
isLoaded.value = true
error.value = null
console.log('✅ Google Maps (New API) cargado con éxito');
} catch (e: any) {
console.error('❌ Error crítico en Nueva API:', e)
let msg = 'Error de carga.'
const errStr = String(e).toLowerCase()
if (errStr.includes('apiprojectmaperror')) {
msg = 'Error de Proyecto: API no habilitada o llave incorrecta.'
} else if (errStr.includes('billing')) {
msg = 'Facturación: Revisa tu cuenta en Google Cloud Console.'
} else if (errStr.includes('referer') || errStr.includes('origin')) {
msg = 'Restricción de Origen: La llave no permite peticiones desde esta App.'
} else {
msg = `Detalle: ${e.message || e}`
}
error.value = `⚠️ Google Maps: ${msg}`
}
}
function initMap(
containerId: string,
center: { lat: number; lng: number },
zoom: number = 12
) {
if (!isLoaded.value) {
console.error('Google Maps not loaded yet')
return
}
const container = document.getElementById(containerId)
if (!container) {
console.error(`Map container with id "${containerId}" not found`)
return
}
// Clear any existing overlays for this map before creating a new one
if (map.value && globalOverlays.has(map.value)) {
clearAllOverlaysForMap(map.value)
}
try {
map.value = new google.maps.Map(container, {
center,
zoom,
disableDefaultUI: true,
})
} catch (e: any) {
console.error('❌ Error inicializando el objeto Map:', e);
error.value = `Error de inicialización: ${e.message || e}`;
}
// Initialize overlay tracking for this map
if (map.value && !globalOverlays.has(map.value)) {
globalOverlays.set(map.value, new Set())
}
}
function addMarker(
position: { lat: number; lng: number },
options?: {
title?: string
draggable?: boolean
icon?: google.maps.Icon | google.maps.Symbol | string
onDragEnd?: (pos: { lat: number; lng: number }) => void
}
): google.maps.Marker | null {
if (!map.value) {
console.error('Map not initialized')
return null
}
const marker = new google.maps.Marker({
position,
map: map.value,
title: options?.title,
draggable: options?.draggable,
icon: options?.icon,
})
if (options?.onDragEnd) {
marker.addListener('dragend', () => {
const pos = marker.getPosition()
if (pos) {
options.onDragEnd!({ lat: pos.lat(), lng: pos.lng() })
}
})
}
// Track in global overlay tracker
if (map.value) {
if (!globalOverlays.has(map.value)) {
globalOverlays.set(map.value, new Set())
}
globalOverlays.get(map.value)!.add(marker)
}
return marker
}
function addNumberedMarker(
position: { lat: number; lng: number },
number: number,
title?: string,
onClick?: () => void
): google.maps.Marker | null {
if (!map.value) {
console.error('Map not initialized')
return null
}
// Note: google.maps.Marker is deprecated but still works
// We'll keep using it for now as AdvancedMarkerElement requires additional setup
// TODO: Migrate to google.maps.marker.AdvancedMarkerElement in the future
const marker = new google.maps.Marker({
position,
map: map.value,
title,
icon: {
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#FEE715', // Amarillo marca
fillOpacity: 1,
strokeColor: '#101820', // Negro marca
strokeWeight: 2,
scale: 14,
},
label: {
text: number.toString(),
color: '#101820',
fontSize: '13px',
fontWeight: '900',
},
})
if (onClick) {
marker.addListener('click', onClick)
}
// Track in global overlay tracker
if (map.value) {
if (!globalOverlays.has(map.value)) {
globalOverlays.set(map.value, new Set())
}
globalOverlays.get(map.value)!.add(marker)
}
return marker
}
function addPolyline(path: Array<{ lat: number; lng: number }>): google.maps.Polyline | null {
if (!map.value) {
console.error('Map not initialized')
return null
}
const polyline = new google.maps.Polyline({
path,
geodesic: true,
strokeColor: '#101820', // Negro premium
strokeOpacity: 0.8,
strokeWeight: 5,
map: map.value,
})
// Track in global overlay tracker
if (map.value) {
if (!globalOverlays.has(map.value)) {
globalOverlays.set(map.value, new Set())
}
globalOverlays.get(map.value)!.add(polyline)
}
return polyline
}
function fitBounds(path: Array<{ lat: number; lng: number }>) {
if (!map.value || path.length === 0) {
return
}
const bounds = new google.maps.LatLngBounds()
path.forEach((point) => {
bounds.extend(new google.maps.LatLng(point.lat, point.lng))
})
map.value.fitBounds(bounds)
}
function setCenter(lat: number, lng: number) {
if (map.value) {
map.value.setCenter({ lat, lng })
}
}
function setZoom(zoom: number) {
if (map.value) {
map.value.setZoom(zoom)
}
}
function clearAllOverlays() {
if (!map.value) {
return
}
clearAllOverlaysForMap(map.value)
}
function clearAllOverlaysForMap(targetMap: google.maps.Map) {
const overlays = globalOverlays.get(targetMap)
// Remove all tracked overlays from the map
if (overlays) {
const overlayCount = overlays.size
overlays.forEach((overlay) => {
if (overlay) {
try {
if ('setMap' in overlay && typeof overlay.setMap === 'function') {
overlay.setMap(null)
}
if ('remove' in overlay && typeof overlay.remove === 'function') {
overlay.remove()
}
} catch (e) {
// Ignore errors when removing overlays
console.warn('Error removing overlay:', e)
}
}
})
// Clear the set
overlays.clear()
console.log(`Cleared ${overlayCount} tracked overlays`)
}
// Manual DOM scraping fallback removed as it causes "removeChild" errors
// with Google Maps' native OverlayView management.
}
function addHtmlMarker(
position: { lat: number; lng: number },
htmlContent: string,
offset: { x: number; y: number } = { x: 0, y: 0 }
) {
if (!map.value) return null;
class CustomOverlay extends google.maps.OverlayView {
private div: HTMLElement | null = null;
private pos: google.maps.LatLng;
constructor(pos: google.maps.LatLng) {
super();
this.pos = pos;
}
onAdd() {
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.cursor = 'pointer';
div.innerHTML = htmlContent;
this.div = div;
const panes = this.getPanes();
panes?.overlayMouseTarget.appendChild(div);
}
draw() {
const overlayProjection = this.getProjection();
const point = overlayProjection.fromLatLngToDivPixel(this.pos);
if (point && this.div) {
this.div.style.left = (point.x + offset.x) + 'px';
this.div.style.top = (point.y + offset.y) + 'px';
}
}
onRemove() {
if (this.div) {
try {
// Safer element removal
if (this.div.parentNode) {
this.div.parentNode.removeChild(this.div);
} else {
this.div.remove();
}
} catch (e) {
console.warn('CustomOverlay: element already removed or parent mismatch', e);
}
this.div = null;
}
}
setPosition(newPos: { lat: number; lng: number }) {
this.pos = new google.maps.LatLng(newPos.lat, newPos.lng);
this.draw();
}
}
const overlay = new CustomOverlay(new google.maps.LatLng(position.lat, position.lng));
overlay.setMap(map.value);
// Track for cleanup
if (!globalOverlays.has(map.value)) {
globalOverlays.set(map.value, new Set());
}
globalOverlays.get(map.value)!.add(overlay as any);
return overlay;
}
onMounted(() => {
loadMaps()
})
return {
map,
isLoaded,
error,
loadMaps,
initMap,
addMarker,
addHtmlMarker,
addNumberedMarker,
addPolyline,
fitBounds,
setCenter,
setZoom,
clearAllOverlays,
}
}

View File

@ -0,0 +1,16 @@
import { createI18n } from 'vue-i18n'
import es from './locales/es.json'
import en from './locales/en.json'
const i18n = createI18n({
legacy: false,
locale: 'es', // Spanish as default
fallbackLocale: 'es',
messages: {
es,
en,
},
})
export default i18n

View File

@ -0,0 +1,144 @@
{
"common": {
"loading": "Loading...",
"error": "Error",
"noData": "No data available",
"select": "Select",
"clear": "Clear",
"clearSelection": "Clear selection"
},
"navigation": {
"map": "Map",
"schedules": "Schedules",
"routes": "Routes",
"favorites": "Favorites",
"taxi": "Taxi",
"coupons": "Offers",
"discover": "Discover",
"profile": "Profile"
},
"favorites": {
"title": "My Favorites",
"subtitle": "Save your favorite routes, taxis, and businesses for quick access.",
"removeConfirm": "Are you sure you want to remove this favorite?",
"saved": "Saved in favorites",
"contact": "Tap to contact",
"viewDetails": "View details",
"viewSchedules": "Tap to view schedules",
"tabs": {
"routes": "Routes",
"taxis": "Taxis",
"businesses": "Businesses",
"coupons": "Offers"
},
"empty": {
"routes": "You don't have any saved favorite routes.",
"taxis": "You don't have any saved favorite taxis.",
"businesses": "You don't have any saved favorite businesses.",
"coupons": "You don't have any saved favorite offers."
},
"cta": {
"routes": "Explore Routes",
"taxis": "View Directory",
"businesses": "Discover Businesses",
"coupons": "View Offers"
}
},
"header": {
"title": "SIBU",
"switchToLightMode": "Switch to light mode",
"switchToDarkMode": "Switch to dark mode"
},
"map": {
"title": "Map",
"loadingMap": "Loading map...",
"mapLoadingError": "Map Loading Error",
"commonFixes": "Common fixes:",
"goToConsole": "Go to Google Cloud Console",
"enableMapsApi": "Enable Maps JavaScript API for your project",
"verifyApiKey": "Verify your API key is associated with the project",
"enableBilling": "Ensure billing is enabled (required even for free tier)",
"checkApiRestrictions": "Check API key restrictions allow localhost:5173",
"restartServer": "Restart the dev server after changing .env.development",
"selectRoute": "Select a route",
"route": "Route",
"stops": "stops",
"stop": "stop"
},
"schedules": {
"title": "Schedules",
"loadingRoutes": "Loading routes...",
"noRoutesAvailable": "No routes available",
"selectRoute": "Select a route",
"route": "Route",
"schedules": "schedules",
"schedule": "schedule",
"departureTime": "Departure time"
},
"coupons": {
"title": "Offers",
"loadingCoupons": "Loading offers...",
"noCouponsAvailable": "No offers available",
"off": "OFF",
"searchPlaceholder": "Search offers...",
"filterByCategory": "Filter by category",
"apply": "Apply",
"offerDetails": "Offer Details",
"description": "Description",
"validity": "Validity",
"category": "Category",
"viewLocation": "View location",
"validUntil": "Valid until",
"tomorrow": "Tomorrow",
"active": "Active",
"offersCount": "{count} offer | {count} offers"
},
"taxi": {
"title": "Transport Hub",
"tabLocal": "Local Taxis",
"tabIntercity": "Tourist Trips",
"loadingTaxis": "Loading directory...",
"noTaxisAvailable": "No taxis registered in this area.",
"area": "Zone",
"shift": "Schedule",
"englishSpeakers": "Bilingual drivers",
"callNow": "Call now",
"englishLabel": "ENGLISH",
"allZones": "All zones",
"dayShift": "Day",
"afternoonShift": "Afternoon",
"nightShift": "Night"
},
"shuttle": {
"title": "Tourist Trips & Shuttles",
"reserve": "Book via WhatsApp",
"perPerson": "per person",
"privateTrip": "private trip",
"duration": "Est. Duration",
"departure": "Departures",
"noShuttles": "No tourist routes available at the moment.",
"filterRoute": "Filter by route",
"allRoutes": "All routes",
"tripType": "Trip type",
"oneWay": "Outbound",
"roundTrip": "Return",
"both": "Both"
},
"busStop": {
"loadingDetails": "Loading bus stop details...",
"amenities": "Amenities",
"shelter": "Shelter",
"seating": "Seating",
"accessible": "Accessible"
},
"discover": {
"title": "Discover",
"subtitle": "Explore the best places in Chiriqui",
"filterLabel": "Filter by area:",
"allAreas": "All",
"loading": "Searching for treasures...",
"empty": "No places found in this area yet.",
"exploreMore": "Explore Place",
"tourism": "Tourism"
}
}

View File

@ -0,0 +1,145 @@
{
"common": {
"loading": "Cargando...",
"error": "Error",
"noData": "No hay datos disponibles",
"select": "Seleccionar",
"clear": "Limpiar",
"clearSelection": "Limpiar selección"
},
"navigation": {
"map": "Mapa",
"schedules": "Horarios",
"routes": "Rutas",
"favorites": "Favoritos",
"taxi": "Transporte",
"coupons": "Ofertas",
"discover": "Descubrir",
"profile": "Perfil"
},
"favorites": {
"title": "Mis Favoritos",
"subtitle": "Guarda tus rutas, taxis y negocios preferidos para acceder rápido.",
"removeConfirm": "¿Estás seguro de que quieres eliminar este favorito?",
"saved": "Guardado en favoritos",
"contact": "Toca para contactar",
"viewDetails": "Ver detalles",
"viewSchedules": "Toque para ver horarios",
"tabs": {
"routes": "Rutas",
"taxis": "Transporte",
"businesses": "Negocios",
"coupons": "Eventos"
},
"empty": {
"subtitle": "Aún no tienes favoritos",
"routes": "No tienes rutas favoritas guardadas.",
"taxis": "No tienes taxis favoritos guardados.",
"businesses": "No tienes negocios favoritos guardados.",
"coupons": "No tienes eventos favoritos guardados."
},
"cta": {
"routes": "Explorar Rutas",
"taxis": "Ver Directorio",
"businesses": "Descubrir Negocios",
"coupons": "Ver Eventos"
}
},
"header": {
"title": "SIBU",
"switchToLightMode": "Cambiar a modo claro",
"switchToDarkMode": "Cambiar a modo oscuro"
},
"map": {
"title": "Mapa",
"loadingMap": "Cargando mapa...",
"mapLoadingError": "Error al cargar el mapa",
"commonFixes": "Soluciones comunes:",
"goToConsole": "Ir a Google Cloud Console",
"enableMapsApi": "Habilitar Maps JavaScript API para tu proyecto",
"verifyApiKey": "Verificar que tu clave API esté asociada con el proyecto",
"enableBilling": "Asegurar que la facturación esté habilitada (requerido incluso para el nivel gratuito)",
"checkApiRestrictions": "Verificar que las restricciones de la clave API permitan localhost:5173",
"restartServer": "Reiniciar el servidor de desarrollo después de cambiar .env.development",
"selectRoute": "Seleccionar una ruta",
"route": "Ruta",
"stops": "paradas",
"stop": "parada"
},
"schedules": {
"title": "Horarios",
"loadingRoutes": "Cargando rutas...",
"noRoutesAvailable": "No hay rutas disponibles",
"selectRoute": "Seleccionar una ruta",
"route": "Ruta",
"schedules": "horarios",
"schedule": "horario",
"departureTime": "Hora de salida"
},
"coupons": {
"title": "Ofertas",
"loadingCoupons": "Cargando ofertas...",
"noCouponsAvailable": "No hay ofertas disponibles",
"off": "DESCUENTO",
"searchPlaceholder": "Buscar ofertas...",
"filterByCategory": "Filtrar por categoría",
"apply": "Aplicar",
"offerDetails": "Detalles de la Oferta",
"description": "Descripción",
"validity": "Validez",
"category": "Categoría",
"viewLocation": "Ver ubicación",
"validUntil": "Válido hasta",
"tomorrow": "Mañana",
"active": "Activo",
"offersCount": "{count} oferta | {count} ofertas"
},
"taxi": {
"title": "Centro de Transporte",
"tabLocal": "Taxis Locales",
"tabIntercity": "Viajes Turísticos",
"loadingTaxis": "Cargando directorio...",
"noTaxisAvailable": "No hay taxis registrados en esta zona.",
"area": "Zona",
"shift": "Horario",
"englishSpeakers": "Conductores bilingües",
"callNow": "Llamar ahora",
"englishLabel": "INGLÉS",
"allZones": "Todas las zonas",
"dayShift": "Día",
"afternoonShift": "Tarde",
"nightShift": "Noche"
},
"shuttle": {
"title": "Viajes Turísticos & Shuttles",
"reserve": "Reservar vía WhatsApp",
"perPerson": "por persona",
"privateTrip": "viaje privado",
"duration": "Duración estimada",
"departure": "Salidas",
"noShuttles": "No hay rutas turísticas disponibles en este momento.",
"filterRoute": "Filtrar por ruta",
"allRoutes": "Todas las rutas",
"tripType": "Tipo de viaje",
"oneWay": "Ida",
"roundTrip": "Vuelta",
"both": "Ambos"
},
"busStop": {
"loadingDetails": "Cargando detalles de la parada...",
"amenities": "Servicios",
"shelter": "Refugio",
"seating": "Asientos",
"accessible": "Accesible"
},
"discover": {
"title": "Descubrir",
"subtitle": "Explora los mejores lugares de Chiriquí",
"filterLabel": "Filtrar por área:",
"allAreas": "Todas",
"loading": "Buscando tesoros...",
"empty": "No se encontraron lugares en esta área todavía.",
"exploreMore": "Explorar Lugar",
"tourism": "Turismo"
}
}

23
frontend/src/main.ts Normal file
View File

@ -0,0 +1,23 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import i18n from './i18n'
import './style.css'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(i18n)
app.config.errorHandler = (err, _vm, info) => {
console.error('Global Error Handler:', err, info)
// Display error on screen if possible or alert for dev
if (import.meta.env.DEV) {
alert('Frontend Error: ' + err)
}
}
app.mount('#app')

View File

@ -0,0 +1,161 @@
/** Vue Router configuration */
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'splash',
component: () => import('@/views/SplashScreen.vue'),
},
{
path: '/map',
name: 'map',
component: () => import('@/views/MapView.vue'),
},
{
path: '/discover',
name: 'discover',
component: () => import('@/views/DiscoverView.vue'),
},
{
path: '/business/:id',
name: 'business-details',
component: () => import('@/views/BusinessDetailsView.vue'),
},
{
path: '/routes',
name: 'routes',
component: () => import('@/views/RoutesView.vue'),
},
{
path: '/schedules',
name: 'schedules',
component: () => import('@/views/SchedulesView.vue'),
},
{
path: '/coupons',
name: 'coupons',
component: () => import('@/views/CouponsView.vue'),
},
{
path: '/favorites',
name: 'favorites',
component: () => import('@/views/FavoritesView.vue'),
},
{
path: '/profile',
name: 'profile',
component: () => import('@/views/ProfileView.vue'),
meta: { requiresAuth: true }
},
{
path: '/taxi',
name: 'taxi',
component: () => import('@/views/TaxiView.vue'),
},
{
path: '/bus-stop/:id',
name: 'bus-stop-details',
component: () => import('@/views/BusStopDetailsView.vue'),
},
{
path: '/login',
name: 'auth',
component: () => import('@/views/AuthView.vue'),
},
{
path: '/admin',
name: 'admin-panel',
component: () => import('@/views/AdminPanel.vue'),
meta: { requiresAuth: true, role: 'admin' }
},
{
path: '/admin/bus-stops',
name: 'admin-bus-stops',
component: () => import('@/views/AdminBusStops.vue'),
meta: { requiresAuth: true, role: 'admin' }
},
{
path: '/admin/routes',
name: 'admin-routes',
component: () => import('@/views/AdminRoutes.vue'),
meta: { requiresAuth: true, role: 'admin' }
},
{
path: '/admin/reports',
name: 'admin-reports',
component: () => import('@/views/AdminReports.vue'),
meta: { requiresAuth: true, role: 'admin' }
},
{
path: '/admin/schedules',
name: 'admin-schedules',
component: () => import('@/views/AdminSchedules.vue'),
meta: { requiresAuth: true, role: 'admin' }
},
{
path: '/admin/drivers',
name: 'admin-drivers',
component: () => import('@/views/AdminDrivers.vue'),
meta: { requiresAuth: true, role: 'admin' }
},
{
path: '/admin/analytics',
name: 'admin-analytics',
component: () => import('@/views/StrategicAnalytics.vue'),
meta: { requiresAuth: true, role: 'admin' }
},
{
path: '/admin/taxis',
name: 'admin-taxis',
component: () => import('@/views/AdminTaxis.vue'),
meta: { requiresAuth: true, role: 'admin' }
},
{
path: '/admin/shuttles',
name: 'admin-shuttles',
component: () => import('@/views/AdminShuttles.vue'),
meta: { requiresAuth: true, role: 'admin' }
},
{
path: '/promoter',
name: 'promoter-dashboard',
component: () => import('@/views/PromoterDashboard.vue'),
meta: { requiresAuth: true, role: ['PROMOTER', 'ADMIN'] }
},
{
path: '/driver',
name: 'driver-dashboard',
component: () => import('@/views/DriverDashboard.vue'),
meta: { requiresAuth: true, role: ['DRIVER', 'ADMIN'] }
},
],
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('auth_token')
const role = localStorage.getItem('user_role')?.toUpperCase()
if (to.meta.requiresAuth && !token) {
next('/login')
} else if (to.meta.role) {
const allowedRoles = Array.isArray(to.meta.role) ? to.meta.role : [to.meta.role]
const hasAccess = allowedRoles.some(r => r.toUpperCase() === role)
if (!hasAccess) {
if (role === 'ADMIN') next('/admin')
else if (role === 'DRIVER') next('/driver')
else if (role === 'PROMOTER') next('/promoter')
else next('/map')
} else {
next()
}
} else {
next()
}
})
export default router

View File

@ -0,0 +1,22 @@
import { apiClient } from './apiClient'
export interface AnalyticsEvent {
event_name: 'app_open' | 'screen_view' | 'route_selected' | 'stop_selected' | 'schedule_viewed' | 'reminder_created' | 'promo_view' | 'promo_click' | 'taxi_view' | 'taxi_click' | 'shuttle_view' | 'shuttle_contact' | 'business_view' | 'business_contact'
screen_name?: string
item_id?: string
properties?: Record<string, any>
}
export const analyticsService = {
logEvent(event: AnalyticsEvent) {
// Log asynchronously without awaiting to avoid blocking UI
apiClient.post('/api/analytics/event', event).catch(error => {
console.warn('Analytics capture failed:', error)
})
},
async getStats() {
const response = await apiClient.get('/api/analytics/dashboard/stats')
return response.data
}
}

View File

@ -0,0 +1,59 @@
/** Base API client for making HTTP requests to the backend */
import axios from 'axios'
import type { AxiosInstance, AxiosError } from 'axios'
export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
class ApiClient {
private client: AxiosInstance
constructor() {
this.client = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 10000,
})
// Request interceptor
this.client.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
// Handle common errors
if (error.response) {
// Server responded with error status
console.error('API Error:', error.response.status, error.response.data)
} else if (error.request) {
// Request made but no response
console.error('Network Error:', error.request)
} else {
// Something else happened
console.error('Error:', error.message)
}
return Promise.reject(error)
}
)
}
get instance(): AxiosInstance {
return this.client
}
}
export const apiClient = new ApiClient().instance

View File

@ -0,0 +1,55 @@
import { apiClient, API_URL } from './apiClient'
export interface LoginResponse {
access_token: string
token_type: string
role: string
full_name: string
profile_photo_url?: string
}
export const authService = {
async login(params: { email: string; password: string; keep_session?: boolean }): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/api/auth/login', params)
return response.data
},
async registerPassenger(data: any) {
const response = await apiClient.post('/api/auth/register/passenger', data)
return response.data
},
async registerDriver(formData: FormData) {
const response = await apiClient.post('/api/auth/register/driver', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data
},
async getCurrentUser() {
const response = await apiClient.get('/api/auth/me')
return response.data
},
async updateMe(formData: FormData) {
const response = await apiClient.patch('/api/auth/me', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data
},
logout() {
localStorage.removeItem('auth_token')
localStorage.removeItem('user_role')
localStorage.removeItem('user_name')
localStorage.removeItem('profile_photo_url')
},
getApiUrl() {
return API_URL
}
}

View File

@ -0,0 +1,57 @@
/** Service for bus stop-related API calls */
import { apiClient } from './apiClient'
import type { BusStop, Route } from '@/types'
export const busStopsService = {
/** Get all bus stops */
async getAllBusStops(): Promise<BusStop[]> {
const response = await apiClient.get<BusStop[]>('/api/bus-stops')
return response.data
},
/** Get a single bus stop by ID */
async getBusStopById(id: string): Promise<BusStop> {
const response = await apiClient.get<BusStop>(`/api/bus-stops/${id}`)
return response.data
},
/** Get all routes passing through a bus stop */
async getBusStopRoutes(stopId: string): Promise<Route[]> {
const response = await apiClient.get<Route[]>(`/api/bus-stops/${stopId}/routes`)
return response.data
},
/** Get estimated next bus arrivals (Mock Data) */
async getNextBusArrivals(_stopId: string): Promise<{ routeName: string; arrivalTime: string }[]> {
// Mock delay to simulate network request
await new Promise(resolve => setTimeout(resolve, 500));
// Generate some random mock arrivals
const mockArrivals = [
{ routeName: "Ruta Boquete - David", arrivalTime: "5 min" },
{ routeName: "Ruta David - Boquete", arrivalTime: "12 min" },
{ routeName: "Ruta Circular", arrivalTime: "25 min" }
];
// Randomly return 1-3 arrivals
return mockArrivals.slice(0, Math.floor(Math.random() * 3) + 1);
},
/** Create a new bus stop (Admin) */
async createBusStop(data: import('@/types').BusStopCreate): Promise<BusStop> {
const response = await apiClient.post<BusStop>('/api/bus-stops', data)
return response.data
},
/** Update a bus stop (Admin) */
async updateBusStop(id: string, data: import('@/types').BusStopUpdate): Promise<BusStop> {
const response = await apiClient.put<BusStop>(`/api/bus-stops/${id}`, data)
return response.data
},
/** Delete a bus stop (Admin) */
async deleteBusStop(id: string): Promise<void> {
await apiClient.delete(`/api/bus-stops/${id}`)
}
}

View File

@ -0,0 +1,42 @@
/** Service for business-related API calls */
import { apiClient } from './apiClient'
import type { Business } from '@/types'
export const businessService = {
/** Get all businesses */
async getAllBusinesses(): Promise<Business[]> {
const response = await apiClient.get<Business[]>('/api/businesses')
return response.data
},
/** Get a single business by ID */
async getBusiness(id: string): Promise<Business> {
const response = await apiClient.get<Business>(`/api/businesses/${id}`)
return response.data
},
/** Create a new business */
async createBusiness(businessData: FormData): Promise<Business> {
const response = await apiClient.post<Business>('/api/businesses', businessData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data
},
/** Update an existing business */
async updateBusiness(id: string, businessData: FormData): Promise<Business> {
const response = await apiClient.patch<Business>(`/api/businesses/${id}`, businessData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data
},
/** Delete a business */
async deleteBusiness(id: string): Promise<void> {
await apiClient.delete(`/api/businesses/${id}`)
},
}

View File

@ -0,0 +1,61 @@
/** Service for coupon-related API calls */
import { apiClient } from './apiClient'
import type { Coupon } from '@/types'
export interface CouponFilters {
category?: string
is_active?: boolean
active_only?: boolean
}
export const couponsService = {
/** Get all coupons with optional filters */
async getAllCoupons(filters?: CouponFilters): Promise<Coupon[]> {
const response = await apiClient.get<Coupon[]>('/api/coupons', {
params: filters,
})
return response.data
},
/** Get a single coupon by ID */
async getCouponById(id: string): Promise<Coupon> {
const response = await apiClient.get<Coupon>(`/api/coupons/${id}`)
return response.data
},
/** Create a new coupon */
async createCoupon(coupon: Omit<Coupon, 'id' | 'created_at' | 'updated_at'>): Promise<Coupon> {
const response = await apiClient.post<Coupon>('/api/coupons', coupon)
return response.data
},
/** Update an existing coupon */
async updateCoupon(id: string, coupon: Partial<Coupon>): Promise<Coupon> {
const response = await apiClient.patch<Coupon>(`/api/coupons/${id}`, coupon)
return response.data
},
/** Delete a coupon */
async deleteCoupon(id: string): Promise<void> {
await apiClient.delete(`/api/coupons/${id}`)
},
/** Claim a coupon */
async claimCoupon(id: string): Promise<any> {
const response = await apiClient.post(`/api/coupons/${id}/claim`)
return response.data
},
/** Get current user's claimed coupons */
async getMyCoupons(): Promise<any[]> {
const response = await apiClient.get('/api/coupons/my-coupons')
return response.data
},
/** Validate a coupon by code (merchants/drivers only) */
async validateCoupon(code: string): Promise<any> {
const response = await apiClient.post(`/api/coupons/validate/${code}`)
return response.data
}
}

View File

@ -0,0 +1,74 @@
/** Service for favorite-related API calls */
import { apiClient } from './apiClient'
import type { Favorite } from '@/types'
export const favoritesService = {
/** Get all favorites for the current user */
async getMyFavorites(itemType?: string): Promise<Favorite[]> {
const params = itemType ? { item_type: itemType } : {}
const response = await apiClient.get<Favorite[]>('/api/favorites', { params })
return response.data
},
/** Add a new favorite */
async addFavorite(
itemType: 'route' | 'stop' | 'taxi' | 'coupon' | 'business',
itemId: string,
itemName?: string,
itemImage?: string
): Promise<Favorite> {
const response = await apiClient.post<Favorite>('/api/favorites', {
item_type: itemType,
item_id: itemId,
item_name: itemName,
item_image: itemImage
})
return response.data
},
/** Remove a favorite by type and ID */
async removeFavorite(itemType: string, itemId: string): Promise<void> {
await apiClient.delete(`/api/favorites/${itemType}/${itemId}`)
},
/** Remove a favorite by favorite ID (legacy support) */
async removeFavoriteById(favoriteId: string): Promise<void> {
// This requires finding the favorite first to get type and id
const favorites = await this.getMyFavorites()
const favorite = favorites.find(f => f.id === favoriteId)
if (favorite) {
await this.removeFavorite(favorite.item_type, favorite.item_id)
}
},
/** Check if an item is favorited */
async checkFavorite(itemType: string, itemId: string): Promise<boolean> {
try {
const response = await apiClient.get<{ is_favorite: boolean }>(
`/api/favorites/check/${itemType}/${itemId}`
)
return response.data.is_favorite
} catch (error) {
console.error('Error checking favorite:', error)
return false
}
},
/** Toggle favorite status */
async toggleFavorite(
itemType: 'route' | 'stop' | 'taxi' | 'coupon' | 'business',
itemId: string,
itemName?: string,
itemImage?: string
): Promise<boolean> {
const isFavorite = await this.checkFavorite(itemType, itemId)
if (isFavorite) {
await this.removeFavorite(itemType, itemId)
return false
} else {
await this.addFavorite(itemType, itemId, itemName, itemImage)
return true
}
}
}

View File

@ -0,0 +1,28 @@
import { apiClient } from './apiClient';
export interface Report {
id: string;
user_id?: string;
user_name?: string;
message: string;
status: 'pending' | 'resolved' | 'archived';
created_at: string;
}
export const reportsService = {
async sendReport(message: string) {
const response = await apiClient.post('/api/reports', { message });
return response.data;
},
async getReports() {
// This would be for the admin
const response = await apiClient.get('/api/reports');
return response.data;
},
async updateReportStatus(reportId: string, status: string) {
const response = await apiClient.patch(`/api/reports/${reportId}`, { status });
return response.data;
}
};

View File

@ -0,0 +1,56 @@
/** Service for route-related API calls */
import { apiClient } from './apiClient'
import type { Route, BusStop } from '@/types'
export const routesService = {
/** Get all routes with optional filtering */
async getAllRoutes(filters?: { originCity?: string, destinationCity?: string }): Promise<Route[]> {
const response = await apiClient.get<Route[]>('/api/routes', {
params: {
origin_city: filters?.originCity,
destination_city: filters?.destinationCity
}
})
return response.data
},
/** Get a single route by ID */
async getRouteById(id: string): Promise<Route> {
const response = await apiClient.get<Route>(`/api/routes/${id}`)
return response.data
},
/** Get all stops for a route */
async getRouteStops(routeId: string): Promise<BusStop[]> {
const response = await apiClient.get<BusStop[]>(`/api/routes/${routeId}/stops`)
return response.data
},
/** Create a new route (Admin) */
async createRoute(data: import('@/types').RouteCreate): Promise<Route> {
const response = await apiClient.post<Route>('/api/routes', data)
return response.data
},
/** Update a route (Admin) */
async updateRoute(id: string, data: import('@/types').RouteUpdate): Promise<Route> {
const response = await apiClient.put<Route>(`/api/routes/${id}`, data)
return response.data
},
/** Delete a route (Admin) */
async deleteRoute(id: string): Promise<void> {
await apiClient.delete(`/api/routes/${id}`)
},
/** Add a stop to a route (Admin) */
async addStopToRoute(routeId: string, data: import('@/types').RouteStopCreate): Promise<void> {
await apiClient.post(`/api/routes/${routeId}/stops`, data)
},
/** Update a stop on a route (Admin) - including reorder */
async updateRouteStop(routeId: string, stopId: string, data: import('@/types').RouteStopUpdate): Promise<void> {
await apiClient.put(`/api/routes/${routeId}/stops/${stopId}`, data)
}
}

View File

@ -0,0 +1,32 @@
import { apiClient } from './apiClient';
export const schedulesService = {
async getRouteSchedules(routeId: string, onlyPublished = true) {
const response = await apiClient.get('/api/schedules', {
params: { route_id: routeId, only_published: onlyPublished }
});
return response.data;
},
async getStopSchedules(stopId: string, onlyPublished = true) {
const response = await apiClient.get('/api/schedules', {
params: { stop_id: stopId, only_published: onlyPublished }
});
return response.data;
},
async createSchedule(scheduleData: any) {
const response = await apiClient.post('/api/schedules', scheduleData);
return response.data;
},
async updateSchedule(scheduleId: string, updateData: any) {
const response = await apiClient.put(`/api/schedules/${scheduleId}`, updateData);
return response.data;
},
async deleteSchedule(scheduleId: string) {
const response = await apiClient.delete(`/api/schedules/${scheduleId}`);
return response.data;
}
};

View File

@ -0,0 +1,27 @@
/** Service for shuttle-related API calls (Intercity/Tourism) */
import { apiClient } from './apiClient'
import type { Shuttle } from '@/types'
export interface ShuttleFilters {
origin?: string
destination?: string
company_name?: string
trip_type?: string
is_active?: boolean
}
export const shuttlesService = {
/** Get all shuttles with optional filters */
async getAllShuttles(filters?: ShuttleFilters): Promise<Shuttle[]> {
const response = await apiClient.get<Shuttle[]>('/api/shuttles', {
params: filters,
})
return response.data
},
/** Get a single shuttle by ID */
async getShuttleById(id: string): Promise<Shuttle> {
const response = await apiClient.get<Shuttle>(`/api/shuttles/${id}`)
return response.data
},
}

View File

@ -0,0 +1,27 @@
/** Service for taxi-related API calls */
import { apiClient } from './apiClient'
import type { Taxi } from '@/types'
export interface TaxiFilters {
corregimiento?: string
shift?: string
english_speaking?: boolean
is_active?: boolean
}
export const taxisService = {
/** Get all taxis with optional filters */
async getAllTaxis(filters?: TaxiFilters): Promise<Taxi[]> {
const response = await apiClient.get<Taxi[]>('/api/taxis', {
params: filters,
})
return response.data
},
/** Get a single taxi by ID */
async getTaxiById(id: string): Promise<Taxi> {
const response = await apiClient.get<Taxi>(`/api/taxis/${id}`)
return response.data
},
}

View File

@ -0,0 +1,32 @@
import { apiClient } from './apiClient'
export interface TelemetryData {
latitude: number
longitude: number
speed?: number
heading?: number
status?: 'active' | 'offline' | 'break'
}
export interface ActiveUnit {
user_id: string
full_name: string
latitude: number
longitude: number
speed?: number
heading?: number
timestamp: string
vehicle_type: string
license_plate: string
}
export const telemetryService = {
async sendTelemetry(data: TelemetryData) {
return await apiClient.post('/api/telemetry', data)
},
async getActiveUnits(): Promise<ActiveUnit[]> {
const response = await apiClient.get<ActiveUnit[]>('/api/telemetry/active')
return response.data
}
}

View File

@ -0,0 +1,27 @@
import { apiClient } from './apiClient';
export const usersService = {
async searchUsers(email: string) {
const response = await apiClient.get('/api/users/search', {
params: { email }
});
return response.data;
},
async getUserDetails(userId: string) {
const response = await apiClient.get(`/api/users/${userId}`);
return response.data;
},
async getPendingDrivers() {
const response = await apiClient.get('/api/users/pending-drivers');
return response.data;
},
async verifyUser(userId: string, isVerified: boolean) {
const response = await apiClient.post(`/api/users/${userId}/verify`, null, {
params: { is_verified: isVerified }
});
return response.data;
}
};

5
frontend/src/shims-vue.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -0,0 +1,47 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('auth_token'))
const role = ref<string | null>(localStorage.getItem('user_role'))
const userName = ref<string | null>(localStorage.getItem('user_name'))
const isAuthenticated = computed(() => !!token.value)
const isAdmin = computed(() => role.value?.toUpperCase() === 'ADMIN')
const isDriver = computed(() => role.value?.toUpperCase() === 'DRIVER')
const isPromoter = computed(() => role.value?.toUpperCase() === 'PROMOTER')
const isPassenger = computed(() => !role.value || role.value?.toUpperCase() === 'PASSENGER')
function login(newToken: string, newRole: string, newName: string) {
token.value = newToken
role.value = newRole
userName.value = newName
localStorage.setItem('auth_token', newToken)
localStorage.setItem('user_role', newRole)
localStorage.setItem('user_name', newName)
}
function logout() {
token.value = null
role.value = null
userName.value = null
localStorage.removeItem('auth_token')
localStorage.removeItem('user_role')
localStorage.removeItem('user_name')
window.location.href = '/'
}
return {
token,
role,
userName,
isAuthenticated,
isAdmin,
isDriver,
isPromoter,
isPassenger,
login,
logout
}
})

View File

@ -0,0 +1,57 @@
/** Pinia store for bus stop management */
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { BusStop } from '@/types'
import { busStopsService } from '@/services/busStopsService'
export const useBusStopStore = defineStore('busStop', () => {
const selectedStop = ref<BusStop | null>(null)
const busStops = ref<BusStop[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
async function loadBusStops(force = false) {
if (!force && busStops.value.length > 0) {
return
}
isLoading.value = true
error.value = null
try {
busStops.value = await busStopsService.getAllBusStops()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load bus stops'
console.error('Error loading bus stops:', e)
} finally {
isLoading.value = false
}
}
async function loadBusStopById(id: string) {
isLoading.value = true
error.value = null
try {
selectedStop.value = await busStopsService.getBusStopById(id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load bus stop'
console.error('Error loading bus stop:', e)
} finally {
isLoading.value = false
}
}
function setSelectedStop(stop: BusStop | null) {
selectedStop.value = stop
}
return {
selectedStop,
busStops,
isLoading,
error,
loadBusStops,
loadBusStopById,
setSelectedStop,
}
})

View File

@ -0,0 +1,65 @@
/** Pinia store for coupon management */
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Coupon } from '@/types'
import { couponsService, type CouponFilters } from '@/services/couponsService'
export const useCouponStore = defineStore('coupon', () => {
const coupons = ref<Coupon[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const myCoupons = ref<any[]>([])
const filters = ref<CouponFilters>({})
async function loadCoupons(newFilters?: CouponFilters) {
isLoading.value = true
error.value = null
if (newFilters) {
filters.value = newFilters
}
try {
coupons.value = await couponsService.getAllCoupons(filters.value)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load coupons'
console.error('Error loading coupons:', e)
} finally {
isLoading.value = false
}
}
async function loadMyCoupons() {
try {
myCoupons.value = await couponsService.getMyCoupons()
} catch (e) {
console.error('Error loading my coupons:', e)
}
}
async function claimCoupon(id: string) {
try {
await couponsService.claimCoupon(id)
await loadMyCoupons()
return true
} catch (e: any) {
const msg = e.response?.data?.detail || e.message
throw new Error(msg)
}
}
function setFilters(newFilters: CouponFilters) {
filters.value = newFilters
loadCoupons()
}
return {
coupons,
myCoupons,
isLoading,
error,
filters,
loadCoupons,
loadMyCoupons,
claimCoupon,
setFilters,
}
})

View File

@ -0,0 +1,114 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiClient } from '@/services/apiClient'
export interface Favorite {
id: string
user_id: string
item_type: 'coupon' | 'business' | 'taxi' | 'route'
item_id: string
item_name?: string
item_image?: string
created_at: string
}
export const useFavoritesStore = defineStore('favorites', () => {
const favorites = ref<Favorite[]>([])
const isLoading = ref(false)
// Computed
const coupons = computed(() => favorites.value.filter(f => f.item_type === 'coupon'))
const businesses = computed(() => favorites.value.filter(f => f.item_type === 'business'))
const taxis = computed(() => favorites.value.filter(f => f.item_type === 'taxi'))
const routes = computed(() => favorites.value.filter(f => f.item_type === 'route'))
// Actions
async function loadFavorites() {
isLoading.value = true
try {
const response = await apiClient.get('/api/favorites')
favorites.value = response.data
} catch (error) {
console.error('Error loading favorites:', error)
} finally {
isLoading.value = false
}
}
async function addFavorite(
itemType: 'coupon' | 'business' | 'taxi' | 'route',
itemId: string,
itemName?: string,
itemImage?: string
) {
try {
const response = await apiClient.post('/api/favorites', {
item_type: itemType,
item_id: itemId,
item_name: itemName,
item_image: itemImage
})
favorites.value.unshift(response.data)
return true
} catch (error: any) {
if (error.response?.status === 400) {
// Already favorited
return false
}
console.error('Error adding favorite:', error)
throw error
}
}
async function removeFavorite(itemType: string, itemId: string) {
try {
await apiClient.delete(`/api/favorites/${itemType}/${itemId}`)
favorites.value = favorites.value.filter(
f => !(f.item_type === itemType && f.item_id === itemId)
)
return true
} catch (error) {
console.error('Error removing favorite:', error)
throw error
}
}
async function toggleFavorite(
itemType: 'coupon' | 'business' | 'taxi' | 'route',
itemId: string,
itemName?: string,
itemImage?: string
) {
const existing = favorites.value.find(
f => f.item_type === itemType && f.item_id === itemId
)
if (existing) {
await removeFavorite(itemType, itemId)
return false
} else {
await addFavorite(itemType, itemId, itemName, itemImage)
return true
}
}
function isFavorite(itemType: string, itemId: string): boolean {
return favorites.value.some(
f => f.item_type === itemType && f.item_id === itemId
)
}
return {
favorites,
isLoading,
coupons,
businesses,
taxis,
routes,
loadFavorites,
addFavorite,
removeFavorite,
toggleFavorite,
isFavorite
}
})

View File

@ -0,0 +1,39 @@
/** Pinia store for map state */
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { BusStop } from '@/types'
export const useMapStore = defineStore('map', () => {
const markers = ref<BusStop[]>([])
const selectedStop = ref<BusStop | null>(null)
const center = ref({ lat: 8.4177, lng: -82.4270 }) // Panama coordinates (David/Boquete area)
const zoom = ref(12)
function setMarkers(stops: BusStop[]) {
markers.value = stops
}
function setSelectedStop(stop: BusStop | null) {
selectedStop.value = stop
}
function setCenter(lat: number, lng: number) {
center.value = { lat, lng }
}
function setZoom(level: number) {
zoom.value = level
}
return {
markers,
selectedStop,
center,
zoom,
setMarkers,
setSelectedStop,
setCenter,
setZoom,
}
})

View File

@ -0,0 +1,78 @@
/** Pinia store for route management */
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Route, BusStop } from '@/types'
import { routesService } from '@/services/routesService'
export const useRouteStore = defineStore('route', () => {
const selectedRouteId = ref<string | null>(null)
const selectedRouteName = ref<string | null>(null)
const selectedRouteStops = ref<BusStop[]>([])
const allRoutes = ref<Route[]>([])
const isLoadingRoutes = ref(false)
const isLoadingStops = ref(false)
const error = ref<string | null>(null)
const hasSelectedRoute = computed(() => selectedRouteId.value !== null && selectedRouteName.value !== null)
async function loadRoutes(filters?: { originCity?: string, destinationCity?: string }, force = false) {
if (!force && !filters && allRoutes.value.length > 0) {
return
}
isLoadingRoutes.value = true
error.value = null
try {
allRoutes.value = await routesService.getAllRoutes(filters)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load routes'
console.error('Error loading routes:', e)
} finally {
isLoadingRoutes.value = false
}
}
async function loadRouteStops(routeId: string) {
isLoadingStops.value = true
error.value = null
try {
selectedRouteStops.value = await routesService.getRouteStops(routeId)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load route stops'
console.error('Error loading route stops:', e)
selectedRouteStops.value = []
} finally {
isLoadingStops.value = false
}
}
async function selectRoute(routeId: string, routeName: string) {
if (selectedRouteId.value === routeId) return
selectedRouteId.value = routeId
selectedRouteName.value = routeName
selectedRouteStops.value = [] // Clear old stops immediately
await loadRouteStops(routeId)
}
function clearSelection() {
selectedRouteId.value = null
selectedRouteName.value = null
selectedRouteStops.value = []
}
return {
selectedRouteId,
selectedRouteName,
selectedRouteStops,
allRoutes,
isLoadingRoutes,
isLoadingStops,
error,
hasSelectedRoute,
loadRoutes,
loadRouteStops,
selectRoute,
clearSelection,
}
})

View File

@ -0,0 +1,46 @@
/** Pinia store for schedule management */
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { BusSchedule } from '@/types'
import { schedulesService } from '@/services/schedulesService'
export const useScheduleStore = defineStore('schedule', () => {
const schedules = ref<BusSchedule[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
async function loadRouteSchedules(routeId: string) {
isLoading.value = true
error.value = null
try {
schedules.value = await schedulesService.getRouteSchedules(routeId)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load schedules'
console.error('Error loading schedules:', e)
} finally {
isLoading.value = false
}
}
async function loadStopSchedules(stopId: string) {
isLoading.value = true
error.value = null
try {
schedules.value = await schedulesService.getStopSchedules(stopId)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load schedules'
console.error('Error loading schedules:', e)
} finally {
isLoading.value = false
}
}
return {
schedules,
isLoading,
error,
loadRouteSchedules,
loadStopSchedules,
}
})

View File

@ -0,0 +1,36 @@
/** Pinia store for shuttle management (Intercity/Tourism) */
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Shuttle } from '@/types'
import { shuttlesService, type ShuttleFilters } from '@/services/shuttlesService'
export const useShuttleStore = defineStore('shuttle', () => {
const shuttles = ref<Shuttle[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const filters = ref<ShuttleFilters>({})
async function loadShuttles(newFilters?: ShuttleFilters) {
isLoading.value = true
error.value = null
if (newFilters) {
filters.value = newFilters
}
try {
shuttles.value = await shuttlesService.getAllShuttles(filters.value)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load shuttles'
console.error('Error loading shuttles:', e)
} finally {
isLoading.value = false
}
}
return {
shuttles,
isLoading,
error,
filters,
loadShuttles,
}
})

View File

@ -0,0 +1,43 @@
/** Pinia store for taxi management */
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Taxi } from '@/types'
import { taxisService, type TaxiFilters } from '@/services/taxisService'
export const useTaxiStore = defineStore('taxi', () => {
const taxis = ref<Taxi[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const filters = ref<TaxiFilters>({})
async function loadTaxis(newFilters?: TaxiFilters) {
isLoading.value = true
error.value = null
if (newFilters) {
filters.value = newFilters
}
try {
taxis.value = await taxisService.getAllTaxis(filters.value)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load taxis'
console.error('Error loading taxis:', e)
} finally {
isLoading.value = false
}
}
function setFilters(newFilters: TaxiFilters) {
filters.value = newFilters
loadTaxis()
}
return {
taxis,
isLoading,
error,
filters,
loadTaxis,
setFilters,
}
})

View File

@ -0,0 +1,60 @@
/** Pinia store for theme management */
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useThemeStore = defineStore('theme', () => {
// Check localStorage first, then system preference
const getInitialTheme = (): boolean => {
const stored = localStorage.getItem('darkMode')
if (stored !== null) {
return stored === 'true'
}
// Check system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
const isDarkMode = ref<boolean>(getInitialTheme())
// Apply theme to document
function applyTheme() {
if (isDarkMode.value) {
document.documentElement.classList.add('dark')
document.documentElement.classList.remove('light-theme')
} else {
document.documentElement.classList.remove('dark')
document.documentElement.classList.add('light-theme')
}
localStorage.setItem('darkMode', String(isDarkMode.value))
}
function toggleDarkMode() {
isDarkMode.value = !isDarkMode.value
applyTheme()
}
function setDarkMode(value: boolean) {
isDarkMode.value = value
applyTheme()
}
// Watch for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
if (!localStorage.getItem('darkMode')) {
isDarkMode.value = e.matches
applyTheme()
}
}
mediaQuery.addEventListener('change', handleSystemThemeChange)
// Apply theme on initialization
applyTheme()
return {
isDarkMode,
toggleDarkMode,
setDarkMode,
applyTheme,
}
})

5
frontend/src/style.css Normal file
View File

@ -0,0 +1,5 @@
/* Global styles are now managed in App.vue and component-specific styles.
This file has been cleared to prevent layout conflicts. */
* {
box-sizing: border-box;
}

186
frontend/src/types/index.ts Normal file
View File

@ -0,0 +1,186 @@
/** Type definitions for the SIBU transportation app */
export type RouteStatus = 'active' | 'inactive' | 'maintenance'
export type StopType = 'terminal' | 'regular' | 'express_only'
export type ScheduleType = 'weekday' | 'weekend' | 'holiday'
export interface Route {
id: string
name: string
description?: string
color: string
direction: string
origin_city?: string
destination_city?: string
distance_km?: number
estimated_duration_minutes?: number
average_speed_kmh?: number
status: RouteStatus
created_at?: string
updated_at?: string
}
export interface BusStop {
id: string
name: string
latitude: number
longitude: number
city?: string
address?: string
parent_id?: string
side?: string
stop_type: StopType
has_shelter: boolean
has_seating: boolean
is_accessible: boolean
created_at?: string
updated_at?: string
// Route-specific fields (from route_stops junction table) OR global order
stop_order?: number
travel_time_minutes?: number
stop_delay_minutes?: number
is_pickup_point?: boolean
is_dropoff_point?: boolean
}
export interface RouteStop {
id: string
route_id: string
stop_id: string
stop_order: number
travel_time_minutes?: number
stop_delay_minutes?: number
is_pickup_point: boolean
is_dropoff_point: boolean
created_at?: string
}
export interface BusSchedule {
id: string
route_id: string
departure_time: string
frequency_minutes?: number
schedule_type: ScheduleType
is_active: boolean
is_published: boolean
notes?: string
created_at?: string
}
export interface Coupon {
id: string
business_id?: string | null
title: string
description?: string | null
business_name?: string | null
business_address?: string | null
business_phone?: string | null
image_url?: string | null
social_media?: string | null
terms?: string | null
discount_percentage?: number | null
discount_amount?: number | null
category?: string | null
valid_from?: string | null
valid_until?: string | null
is_active: boolean
business?: Business | null
created_at?: string
updated_at?: string
}
export interface Business {
id: string
name: string
address?: string | null
phone?: string | null
image_url?: string | null
social_media?: string | null
category?: string | null
latitude?: number | null
longitude?: number | null
area?: string | null
updated_at?: string
}
export type UserCouponStatus = 'claimed' | 'redeemed' | 'expired'
export interface UserCoupon {
id: string
user_id: string
coupon_id: string
status: UserCouponStatus
redemption_code: string
claimed_at: string
redeemed_at?: string | null
coupon?: Coupon
}
export interface Taxi {
id: string
owner_name: string
phone_number: string
license_plate: string
cooperative?: string
corregimiento: string
shift: string
rating?: number
english_speaking?: boolean
image_url?: string
is_active: boolean
created_at?: string
updated_at?: string
}
export interface Favorite {
id: string
user_id: string
item_type: 'route' | 'stop' | 'taxi'
item_id: string
created_at?: string
}
export type BusStopCreate = Omit<BusStop, 'id' | 'created_at' | 'updated_at'>
export type BusStopUpdate = Partial<BusStopCreate>
export type RouteCreate = Omit<Route, 'id' | 'created_at' | 'updated_at'>
export type RouteUpdate = Partial<RouteCreate>
export interface RouteStopCreate {
stop_id: string
stop_order?: number
travel_time_minutes?: number
stop_delay_minutes?: number
is_pickup_point?: boolean
is_dropoff_point?: boolean
}
export interface RouteStopUpdate {
stop_order?: number
travel_time_minutes?: number
stop_delay_minutes?: number
is_pickup_point?: boolean
is_dropoff_point?: boolean
}
export interface Shuttle {
id: string
route_name: string
description?: string
origin: string
destination: string
vehicle_type: string
company_name?: string
trip_type: 'one_way' | 'round_trip' | 'both'
price_per_person?: number
price_private_trip?: number
estimated_duration: string
departure_times?: string
contact_whatsapp: string
phone_number?: string
english_speaking?: boolean
image_url?: string
is_active: boolean
created_at?: string
updated_at?: string
}

View File

@ -0,0 +1,21 @@
/**
* Formats a time string (e.g., "14:30:00" or "14:30") to 12-hour format (e.g., "02:30 PM").
* Omits seconds.
*/
export function formatTo12Hour(timeStr: string | undefined | null): string {
if (!timeStr) return '';
const [h, m] = timeStr.split(':');
if (h === undefined || m === undefined) return timeStr;
let hours = parseInt(h, 10);
const minutes = m;
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours ? hours : 12; // the hour '0' should be '12'
// Ensure 2-digit minutes and return
return `${hours.toString().padStart(2, '0')}:${minutes.substring(0, 2)} ${ampm}`;
}

View File

@ -0,0 +1,253 @@
<template>
<div class="admin-bus-stops">
<div class="header">
<button class="back-link" @click="$router.push('/admin')">&larr; Volver al Panel</button>
<h1>Gestionar Paradas</h1>
<button class="add-button" @click="openCreate">
<span class="material-icons">add</span> Nueva Parada
</button>
</div>
<div v-if="isLoading">Cargando paradas...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="stops-list">
<div v-for="stop in stops" :key="stop.id" class="stop-card">
<div class="stop-info">
<h3>{{ stop.name }}</h3>
<p>{{ stop.city }} - {{ translateType(stop.stop_type) }}</p>
<div class="badges">
<span v-if="stop.has_shelter" class="badge">Con Techo</span>
<span v-if="stop.has_seating" class="badge">Asientos</span>
<span v-if="stop.is_accessible" class="badge">Accesible</span>
</div>
</div>
<div class="stop-actions">
<button class="icon-btn edit" @click="openEdit(stop)">
<span class="material-icons">edit</span>
</button>
<button class="icon-btn delete" @click="confirmDelete(stop)">
<span class="material-icons">delete</span>
</button>
</div>
</div>
</div>
<!-- Editor Modal -->
<div v-if="showEditor" class="modal-overlay">
<div class="modal-content">
<BusStopEditor
:initial-stop="selectedStop"
@save="handleSave"
@cancel="closeEditor"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { busStopsService } from '@/services/busStopsService'
import type { BusStop } from '@/types'
import BusStopEditor from '@/components/BusStopEditor.vue'
const stops = ref<BusStop[]>([])
const isLoading = ref(true)
const error = ref<string | null>(null)
const showEditor = ref(false)
const selectedStop = ref<BusStop | null>(null)
onMounted(loadStops)
async function loadStops() {
isLoading.value = true
try {
stops.value = await busStopsService.getAllBusStops()
} catch (e) {
error.value = 'Error al cargar las paradas'
} finally {
isLoading.value = false
}
}
function translateType(type: string) {
const types: Record<string, string> = {
'regular': 'Regular',
'terminal': 'Terminal',
'express_only': 'Solo Expreso'
}
return types[type] || type
}
function openCreate() {
selectedStop.value = null
showEditor.value = true
}
function openEdit(stop: BusStop) {
selectedStop.value = stop
showEditor.value = true
}
function closeEditor() {
showEditor.value = false
selectedStop.value = null
}
async function handleSave(data: any) {
try {
if (data.id) {
// Update
const { id, ...updateData } = data
await busStopsService.updateBusStop(id, updateData)
} else {
// Create
await busStopsService.createBusStop(data)
}
await loadStops()
closeEditor()
} catch (e) {
alert('Error al guardar la parada')
}
}
async function confirmDelete(stop: BusStop) {
if (confirm(`¿Estás seguro de que quieres eliminar la parada ${stop.name}?`)) {
try {
await busStopsService.deleteBusStop(stop.id)
await loadStops()
} catch (e) {
alert('Error al eliminar la parada')
}
}
}
</script>
<style scoped>
.admin-bus-stops {
padding: 20px;
max-width: 1000px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.back-link {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
padding: 10px 16px;
border-radius: 8px;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 4px;
}
.back-link:hover {
background: var(--hover-bg);
border-color: var(--accent-color);
color: var(--accent-color);
transform: translateX(-2px);
}
.add-button {
background: #007bff;
color: white;
border: none;
padding: 10px 16px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.stops-list {
display: grid;
gap: 12px;
}
.stop-card {
background: white;
padding: 16px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
display: flex;
justify-content: space-between;
align-items: center;
}
.stop-info h3 {
margin: 0 0 4px 0;
}
.stop-info p {
margin: 0 0 8px 0;
color: #666;
}
.badges {
display: flex;
gap: 8px;
}
.badge {
background: #e9ecef;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
}
.stop-actions {
display: flex;
gap: 8px;
}
.icon-btn {
background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 4px;
}
.icon-btn:hover {
background: #f1f1f1;
}
.edit { color: #f39c12; }
.delete { color: #e74c3c; }
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 24px;
border-radius: 12px;
width: 90%;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
}
</style>

View File

@ -0,0 +1,521 @@
<template>
<div class="admin-dashboard">
<div class="dashboard-header">
<div class="header-main">
<button class="back-btn" @click="router.push('/admin')">
<span class="material-icons">arrow_back</span>
</button>
<div>
<h1>Análisis Estratégico</h1>
<p class="subtitle">Métricas en tiempo real del ecosistema SIBU</p>
</div>
</div>
<div class="header-actions">
<div v-if="lastRefreshed" class="last-sync">
Actualizado: {{ lastRefreshed }}
</div>
<button class="refresh-action" @click="loadStats" :disabled="isLoading">
<span class="material-icons" :class="{ 'spin': isLoading }">refresh</span>
{{ isLoading ? 'Sincronizando...' : 'Refrescar' }}
</button>
</div>
</div>
<div v-if="isLoading && !stats.total_events" class="loading-overlay">
<div class="loader-content">
<div class="spinner"></div>
<p>Procesando datos...</p>
</div>
</div>
<div v-else class="dashboard-grid">
<!-- Top Overview Metrics -->
<div class="metrics-row">
<div class="metric-card primary">
<div class="card-icon"><span class="material-icons">analytics</span></div>
<div class="card-info">
<label>Eventos Totales</label>
<h3>{{ stats.total_events?.toLocaleString() || '0' }}</h3>
</div>
</div>
<div class="metric-card">
<div class="card-icon"><span class="material-icons">speed</span></div>
<div class="card-info">
<label>Pico de Actividad</label>
<h3>{{ stats.peak_hours?.[0]?.hour || '--' }}:00</h3>
</div>
</div>
<div class="metric-card">
<div class="card-icon"><span class="material-icons">visibility</span></div>
<div class="card-info">
<label>Pantalla Principal</label>
<h3>{{ stats.screen_activity?.[0]?.name || 'Mapa' }}</h3>
</div>
</div>
</div>
<!-- Charts Rows -->
<div class="stats-row">
<div class="chart-box flex-66">
<div class="box-header">
<h3>Tendencia de Uso (Últimos 7 días)</h3>
</div>
<div class="chart-container">
<Line v-if="trendChartData" :data="trendChartData" :options="chartOptions" />
</div>
</div>
<div class="chart-box flex-33">
<div class="box-header">
<h3>Distribución por Idioma</h3>
</div>
<div class="chart-container circle">
<Doughnut v-if="langChartData" :data="langChartData" :options="doughnutOptions" />
</div>
</div>
</div>
<div class="stats-row">
<div class="chart-box flex-50">
<div class="box-header">
<h3>Popularidad de Rutas</h3>
</div>
<div class="chart-container">
<Bar v-if="routesChartData" :data="routesChartData" :options="chartOptions" />
</div>
</div>
<div class="chart-box flex-50">
<div class="box-header">
<h3>Visualizaciones de Promociones</h3>
</div>
<div class="chart-container">
<Bar v-if="promosChartData" :data="promosChartData" :options="horizontalBarOptions" />
</div>
</div>
</div>
<div class="stats-row">
<div class="chart-box flex-50">
<div class="box-header">
<h3>Top Acciones (Taxis/Otros)</h3>
</div>
<div class="plain-list">
<div v-for="(taxi, idx) in stats.top_taxis" :key="idx" class="list-row">
<span class="rank">{{ Number(idx) + 1 }}</span>
<span class="label">{{ taxi.id || 'N/A' }}</span>
<span class="val">{{ taxi.count }}</span>
</div>
<p v-if="!stats.top_taxis?.length" class="empty">Sin registros</p>
</div>
</div>
<div class="chart-box flex-50">
<div class="box-header">
<h3>Top Paradas</h3>
</div>
<div class="plain-list">
<div v-for="(stop, idx) in stats.top_stops" :key="idx" class="list-row">
<span class="rank">{{ Number(idx) + 1 }}</span>
<span class="label">{{ stop.id || 'N/A' }}</span>
<span class="val">{{ stop.count }}</span>
</div>
<p v-if="!stats.top_stops?.length" class="empty">Sin registros</p>
</div>
</div>
</div>
<div class="stats-row">
<div class="chart-box flex-100">
<div class="box-header">
<h3>Actividad por Hora</h3>
</div>
<div class="chart-container">
<Bar v-if="hoursChartData" :data="hoursChartData" :options="chartOptions" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
import { analyticsService } from '@/services/analyticsService'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend,
ArcElement
} from 'chart.js'
import { Line, Bar, Doughnut } from 'vue-chartjs'
// Register ChartJS
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend
)
const isLoading = ref(true)
const stats = ref<any>({})
const lastRefreshed = ref('')
async function loadStats() {
isLoading.value = true
try {
stats.value = await analyticsService.getStats()
lastRefreshed.value = new Date().toLocaleTimeString()
} catch (e) {
console.error('Error loading stats')
} finally {
isLoading.value = false
}
}
// Chart Configurations
const chartOptions = computed<any>(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
},
x: {
grid: { display: false },
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
}
}
}))
const horizontalBarOptions = computed<any>(() => ({
indexAxis: 'y' as const,
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
beginAtZero: true,
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
},
y: {
grid: { display: false },
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
}
}
}))
const doughnutOptions = computed<any>(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { color: '#fff', padding: 20 }
}
}
}))
// Computed Chart Data
const trendChartData = computed<any>(() => {
if (!stats.value.daily_trends?.length) return null
return {
labels: stats.value.daily_trends.map((t: any) => t.date.split('-').slice(1).join('/')),
datasets: [{
label: 'Eventos',
data: stats.value.daily_trends.map((t: any) => t.count),
borderColor: '#fee715',
backgroundColor: 'rgba(254, 231, 21, 0.1)',
fill: true,
tension: 0.4
}]
}
})
const langChartData = computed<any>(() => {
if (!stats.value.languages?.length) return null
return {
labels: stats.value.languages.map((l: any) => l.id === 'es' ? 'Español' : 'English'),
datasets: [{
data: stats.value.languages.map((l: any) => l.count),
backgroundColor: ['#fee715', '#64748b'],
borderWidth: 0
}]
}
})
const promosChartData = computed<any>(() => {
if (!stats.value.top_promos?.length) return null
return {
labels: stats.value.top_promos.map((p: any) => p.id),
datasets: [{
label: 'Visualizaciones',
data: stats.value.top_promos.map((p: any) => p.count),
backgroundColor: '#fee715',
borderRadius: 6
}]
}
})
const routesChartData = computed<any>(() => {
if (!stats.value.top_routes?.length) return null
return {
labels: stats.value.top_routes.map((r: any) => r.id),
datasets: [{
label: 'Consultas',
data: stats.value.top_routes.map((r: any) => r.count),
backgroundColor: '#fee715',
borderRadius: 8
}]
}
})
const hoursChartData = computed<any>(() => {
if (!stats.value.peak_hours?.length) return null
// Sort by hour
const sorted = [...stats.value.peak_hours].sort((a,b) => a.hour - b.hour)
return {
labels: sorted.map(h => `${h.hour}h`),
datasets: [{
label: 'Actividad',
data: sorted.map(h => h.count),
backgroundColor: 'rgba(254, 231, 21, 0.2)',
hoverBackgroundColor: '#fee715',
borderRadius: 4
}]
}
})
onMounted(loadStats)
</script>
<style scoped>
.admin-dashboard {
padding: 32px 24px;
max-width: 1400px;
margin: 0 auto;
min-height: 100vh;
padding-bottom: 120px;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 48px;
gap: 24px;
}
.header-main {
display: flex;
align-items: center;
gap: 20px;
}
.back-btn {
background: var(--hover-bg);
border: 1px solid var(--border-color);
width: 44px;
height: 44px;
border-radius: 14px;
cursor: pointer;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.back-btn:hover { background: var(--active-color); color: #101820; transform: scale(1.05); }
h1 {
font-size: clamp(2rem, 5vw, 2.5rem);
font-weight: 900;
margin: 0;
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.04em;
}
.subtitle { color: var(--text-secondary); margin: 6px 0 0 0; font-weight: 500; }
.header-actions {
display: flex;
align-items: center;
gap: 24px;
}
.last-sync { font-size: 13px; color: var(--text-secondary); font-weight: 600; }
.refresh-action {
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
color: #101820;
border: none;
padding: 12px 24px;
border-radius: 14px;
font-weight: 900;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: all 0.3s;
box-shadow: 0 10px 20px rgba(254, 231, 21, 0.2);
}
.refresh-action:hover { transform: translateY(-2px); box-shadow: 0 15px 30px rgba(254, 231, 21, 0.3); }
.refresh-action:disabled { opacity: 0.5; cursor: not-allowed; }
.spin { animation: rotation 1s infinite linear; }
@keyframes rotation { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* Metrics */
.metrics-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
margin-bottom: 24px;
}
.metric-card {
background: var(--card-bg);
backdrop-filter: blur(12px);
padding: 28px;
border-radius: 28px;
display: flex;
align-items: center;
gap: 24px;
border: 1px solid var(--border-color);
transition: all 0.3s;
}
.metric-card:hover { transform: translateY(-4px); border-color: var(--active-color); }
.metric-card.primary {
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
color: #101820;
border: none;
}
.card-icon {
width: 60px;
height: 60px;
background: rgba(255,255,255,0.05);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.metric-card.primary .card-icon { background: rgba(0,0,0,0.1); }
.card-icon .material-icons { font-size: 32px; color: var(--active-color); }
.metric-card.primary .card-icon .material-icons { color: #101820; }
.card-info label { display: block; font-size: 13px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.1em; font-weight: 800; margin-bottom: 4px; }
.metric-card.primary .card-info label { color: #101820; opacity: 0.7; }
.card-info h3 { font-size: 28px; font-weight: 900; margin: 0; letter-spacing: -0.02em; }
/* Charts */
.dashboard-grid {
display: flex;
flex-direction: column;
gap: 32px;
}
.chart-box {
background: var(--card-bg);
backdrop-filter: blur(12px);
border-radius: 32px;
padding: 32px;
border: 1px solid var(--border-color);
}
.box-header { margin-bottom: 32px; }
.box-header h3 { font-size: 1.25rem; font-weight: 900; color: var(--text-primary); display: flex; align-items: center; gap: 12px; letter-spacing: -0.02em; }
.chart-container { height: 320px; position: relative; }
.chart-container.circle { height: 280px; }
.stats-row {
display: flex;
gap: 32px;
}
.flex-33 { flex: 1; }
.flex-50 { flex: 1; }
.flex-66 { flex: 2; }
.flex-100 { flex: 1; width: 100%; }
/* Plain Lists */
.plain-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.list-row {
display: flex;
align-items: center;
padding: 16px;
background: var(--bg-secondary);
border-radius: 16px;
border: 1px solid var(--border-color);
transition: all 0.3s;
}
.list-row:hover { transform: translateX(8px); border-color: var(--active-color); }
.rank { width: 32px; color: var(--text-secondary); font-weight: 900; font-size: 14px; }
.label { flex: 1; font-weight: 700; color: var(--text-primary); }
.val { font-weight: 900; color: var(--active-color); font-size: 1.1rem; }
.empty { text-align: center; color: var(--text-secondary); padding: 48px; font-weight: 600; }
.loading-overlay {
height: 60vh;
display: flex;
align-items: center;
justify-content: center;
}
.loader-content { text-align: center; }
.spinner {
width: 60px;
height: 60px;
border: 4px solid var(--border-color);
border-top-color: var(--active-color);
border-radius: 50%;
animation: rotation 1s infinite linear;
margin: 0 auto 24px;
}
@media (max-width: 768px) {
.stats-row { flex-direction: column; }
.metrics-row { grid-template-columns: 1fr; }
.dashboard-header { flex-direction: column; align-items: flex-start; }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,280 @@
<template>
<div class="admin-panel">
<div class="header-section">
<div class="badge">SISTEMA CENTRAL</div>
<h1>Panel de Control</h1>
<p class="subtitle">Ecosistema Administrativo SIBU</p>
</div>
<div class="dashboard-sections">
<!-- Sector: Inteligencia y Control -->
<section class="admin-section">
<div class="section-header">
<span class="material-icons">insights</span>
<h2>Inteligencia y Control</h2>
</div>
<div class="category-grid">
<div class="action-card" @click="router.push('/admin/analytics')">
<div class="card-icon"><span class="material-icons">analytics</span></div>
<div class="card-content">
<h3>Análisis</h3>
<p>Métricas en tiempo real.</p>
</div>
</div>
<div class="action-card" @click="router.push('/admin/reports')">
<div class="card-icon"><span class="material-icons">report_problem</span></div>
<div class="card-content">
<h3>Reportes</h3>
<p>Incidencias de usuarios.</p>
</div>
</div>
</div>
</section>
<!-- Sector: Infraestructura de Transporte -->
<section class="admin-section">
<div class="section-header">
<span class="material-icons">settings_input_component</span>
<h2>Infraestructura</h2>
</div>
<div class="category-grid">
<div class="action-card" @click="router.push('/admin/routes')">
<div class="card-icon"><span class="material-icons">navigation</span></div>
<div class="card-content">
<h3>Rutas</h3>
<p>Gestión de trayectos.</p>
</div>
</div>
<div class="action-card" @click="router.push('/admin/bus-stops')">
<div class="card-icon"><span class="material-icons">location_on</span></div>
<div class="card-content">
<h3>Paradas</h3>
<p>Puntos de abordaje.</p>
</div>
</div>
<div class="action-card" @click="router.push('/admin/schedules')">
<div class="card-icon"><span class="material-icons">schedule</span></div>
<div class="card-content">
<h3>Horarios</h3>
<p>Frecuencias y salidas.</p>
</div>
</div>
</div>
</section>
<!-- Sector: Flota y Servicios -->
<section class="admin-section">
<div class="section-header">
<span class="material-icons">delivery_dining</span>
<h2>Flota y Servicios</h2>
</div>
<div class="category-grid">
<div class="action-card" @click="router.push('/admin/shuttles')">
<div class="card-icon"><span class="material-icons">airport_shuttle</span></div>
<div class="card-content">
<h3>Shuttles</h3>
<p>Viajes turísticos.</p>
</div>
</div>
<div class="action-card" @click="router.push('/admin/taxis')">
<div class="card-icon"><span class="material-icons">local_taxi</span></div>
<div class="card-content">
<h3>Taxis</h3>
<p>Directorio de apoyo.</p>
</div>
</div>
<div class="action-card" @click="router.push('/admin/drivers')">
<div class="card-icon"><span class="material-icons">badge</span></div>
<div class="card-content">
<h3>Conductores</h3>
<p>Gestión de personal.</p>
</div>
</div>
</div>
</section>
<!-- Sector: Comercial -->
<section class="admin-section">
<div class="section-header">
<span class="material-icons">hub</span>
<h2>Ecosistema Comercial</h2>
</div>
<div class="category-grid">
<div class="action-card promoter-card" @click="router.push('/promoter')">
<div class="card-icon"><span class="material-icons">storefront</span></div>
<div class="card-content">
<h3>Negocios</h3>
<p>Promos y locales.</p>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<style scoped>
.admin-panel {
padding: 60px 24px 120px;
max-width: 1200px;
margin: 0 auto;
min-height: 100vh;
}
.header-section {
text-align: center;
margin-bottom: 60px;
}
.badge {
display: inline-block;
padding: 6px 14px;
background: rgba(254, 231, 21, 0.1);
color: var(--active-color);
border-radius: 100px;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.15em;
margin-bottom: 16px;
border: 1px solid rgba(254, 231, 21, 0.2);
}
h1 {
font-size: clamp(2.2rem, 5vw, 3.2rem);
font-weight: 900;
color: var(--text-primary);
letter-spacing: -0.04em;
margin: 0;
}
.subtitle {
color: var(--text-secondary);
font-size: 1rem;
font-weight: 500;
margin-top: 6px;
letter-spacing: 0.05em;
}
.dashboard-sections {
display: flex;
flex-direction: column;
gap: 56px;
}
.admin-section {
display: flex;
flex-direction: column;
gap: 24px;
align-items: center; /* Centra el contenido de la sección */
}
.section-header {
display: flex;
align-items: center;
gap: 12px;
color: var(--active-color);
padding: 0 20px 12px;
border-bottom: 1px solid var(--border-color);
width: 100%;
max-width: 800px; /* Línea de división elegante y no tan larga */
justify-content: center;
}
.section-header .material-icons {
font-size: 1.2rem;
}
.section-header h2 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 800;
margin: 0;
color: var(--text-secondary);
}
.category-grid {
display: flex;
flex-wrap: wrap;
justify-content: center; /* ESTO CENTRA LAS TARJETAS */
gap: 24px;
width: 100%;
}
.action-card {
background: var(--card-bg);
backdrop-filter: blur(12px);
border-radius: 24px;
padding: 24px 28px;
border: 1px solid var(--border-color);
cursor: pointer;
display: flex;
align-items: center;
gap: 20px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
width: 340px; /* Ancho fijo para mantener la simetría */
min-height: 110px;
}
.action-card:hover {
transform: translateY(-5px);
border-color: var(--active-color);
background: rgba(254, 231, 21, 0.03);
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.2);
}
.card-icon {
width: 52px;
height: 52px;
background: var(--bg-secondary);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s;
}
.action-card:hover .card-icon {
background: var(--active-color);
transform: rotate(-10deg);
}
.card-icon .material-icons {
font-size: 24px;
color: var(--active-color);
}
.action-card:hover .card-icon .material-icons {
color: #101820;
}
.card-content h3 {
margin: 0 0 4px;
font-size: 1.15rem;
font-weight: 800;
color: var(--text-primary);
}
.card-content p {
margin: 0;
color: var(--text-secondary);
font-size: 0.85rem;
line-height: 1.4;
}
.promoter-card {
background: linear-gradient(135deg, rgba(254, 231, 21, 0.05) 0%, rgba(30, 41, 59, 0.2) 100%);
}
@media (max-width: 600px) {
.admin-panel { padding: 30px 16px 120px; }
.category-grid { grid-template-columns: 1fr; }
.header-section { text-align: center; padding: 0; }
}
</style>

View File

@ -0,0 +1,318 @@
<template>
<div class="admin-reports">
<div class="header">
<button class="back-link" @click="$router.push('/admin')">
<span class="material-icons">arrow_back</span> Volver al Panel
</button>
<h1>Reportes de Usuarios</h1>
</div>
<div v-if="isLoading" class="loading">
<div class="spinner"></div>
<p>Cargando reportes...</p>
</div>
<div v-else-if="reports.length > 0" class="reports-container">
<div class="stats-overview">
<div class="stat-card">
<span class="stat-value">{{ reports.length }}</span>
<span class="stat-label">Total Reportes</span>
</div>
<div class="stat-card pending">
<span class="stat-value">{{ reports.filter(r => r.status === 'pending').length }}</span>
<span class="stat-label">Pendientes</span>
</div>
</div>
<div class="reports-grid">
<div v-for="report in sortedReports" :key="report.id" class="report-card" :class="report.status">
<div class="report-header">
<div class="user-info">
<span class="material-icons">account_circle</span>
<div>
<h3>{{ report.user_name || 'Usuario Anónimo' }}</h3>
<span class="date">{{ formatDate(report.created_at) }}</span>
</div>
</div>
<div class="status-badge" :class="report.status">
{{ statusDisplay(report.status) }}
</div>
</div>
<div class="report-body">
<p>{{ report.message }}</p>
</div>
<div class="report-actions">
<button
v-if="report.status === 'pending'"
@click="handleUpdateStatus(report.id, 'resolved')"
class="btn-resolve"
>
<span class="material-icons">check_circle</span> Marcar como Resuelto
</button>
<button
v-else-if="report.status === 'resolved'"
@click="handleUpdateStatus(report.id, 'archived')"
class="btn-archive"
>
<span class="material-icons">archive</span> Archivar
</button>
</div>
</div>
</div>
</div>
<div v-else class="no-results">
<span class="material-icons">info</span>
<p>No hay reportes nuevos en este momento.</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { reportsService, type Report } from '@/services/reportsService'
const reports = ref<Report[]>([])
const isLoading = ref(true)
const sortedReports = computed(() => {
return [...reports.value].sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
})
onMounted(async () => {
await fetchReports()
})
async function fetchReports() {
isLoading.value = true
try {
reports.value = await reportsService.getReports()
} catch (e) {
console.error('Error fetching reports:', e)
} finally {
isLoading.value = false
}
}
async function handleUpdateStatus(id: string, status: string) {
try {
await reportsService.updateReportStatus(id, status)
await fetchReports()
} catch (e) {
alert('Error al actualizar el estado del reporte')
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('es-ES', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})
}
function statusDisplay(status: string) {
const mapping: Record<string, string> = {
'pending': 'Pendiente',
'resolved': 'Resuelto',
'archived': 'Archivado'
}
return mapping[status] || status
}
</script>
<style scoped>
.admin-reports {
padding: 48px 24px;
max-width: 1000px;
margin: 0 auto;
}
.header {
margin-bottom: 40px;
}
.back-link {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
margin-bottom: 16px;
transition: color 0.3s;
}
.back-link:hover {
color: var(--active-color);
}
h1 {
font-size: 2.5rem;
font-weight: 900;
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
margin: 0;
}
.stats-overview {
display: flex;
gap: 24px;
margin-bottom: 32px;
}
.stat-card {
background: var(--card-bg);
padding: 24px;
border-radius: 20px;
border: 1px solid var(--border-color);
flex: 1;
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 2rem;
font-weight: 900;
color: var(--active-color);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
}
.reports-grid {
display: grid;
gap: 20px;
}
.report-card {
background: var(--card-bg);
backdrop-filter: blur(12px);
border-radius: 24px;
padding: 24px;
border: 1px solid var(--border-color);
transition: all 0.3s ease;
}
.report-card:hover {
border-color: rgba(254, 231, 21, 0.3);
transform: translateY(-4px);
}
.report-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.user-info {
display: flex;
gap: 12px;
align-items: center;
}
.user-info h3 {
margin: 0;
font-size: 1.1rem;
}
.user-info .date {
font-size: 0.8rem;
color: var(--text-secondary);
}
.status-badge {
padding: 4px 12px;
border-radius: 100px;
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
}
.status-badge.pending { background: rgba(239, 68, 68, 0.1); color: #ef4444; }
.status-badge.resolved { background: rgba(34, 197, 94, 0.1); color: #22c55e; }
.status-badge.archived { background: var(--bg-secondary); color: var(--text-secondary); }
.report-body {
margin-bottom: 24px;
line-height: 1.6;
color: var(--text-primary);
}
.report-actions {
display: flex;
justify-content: flex-end;
}
.btn-resolve, .btn-archive {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s;
}
.btn-resolve {
background: var(--active-color);
color: #101820;
border: none;
}
.btn-resolve:hover {
transform: scale(1.05);
box-shadow: 0 4px 15px rgba(254, 231, 21, 0.3);
}
.btn-archive {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.btn-archive:hover {
border-color: var(--text-secondary);
color: var(--text-primary);
}
.no-results, .loading {
text-align: center;
padding: 80px 24px;
color: var(--text-secondary);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(254, 231, 21, 0.1);
border-top-color: var(--active-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 600px) {
.admin-reports { padding: 24px 16px; }
.stats-overview { flex-direction: column; }
}
</style>

View File

@ -0,0 +1,592 @@
<template>
<div class="admin-routes">
<div class="header">
<button class="back-link" @click="router.push('/admin')">&larr; Volver al Panel</button>
<h1>Gestionar Rutas</h1>
<button class="add-button" @click="createRoute">
<span class="material-icons">add</span> Nueva Ruta
</button>
</div>
<!-- Route List -->
<div v-if="!selectedRoute" class="route-list">
<div v-for="route in routes" :key="route.id" class="route-card" @click="selectRoute(route)">
<div class="route-info">
<h3>{{ route.name }}</h3>
<p>{{ route.origin_city }} &rarr; {{ route.destination_city }}</p>
<div class="status" :class="route.status">{{ translateStatus(route.status) }}</div>
</div>
<span class="material-icons">chevron_right</span>
</div>
</div>
<!-- Single Route Editor -->
<div v-else class="route-editor">
<div class="editor-header">
<button @click="selectedRoute = null">Cerrar</button>
<h2>Editar Ruta: {{ selectedRoute.name }}</h2>
</div>
<div class="route-details-form">
<div class="form-group">
<label>Velocidad Promedio (km/h)</label>
<input v-model.number="selectedRoute.average_speed_kmh" @change="updateRouteDetails" type="number" placeholder="ej. 30">
</div>
<div class="form-group">
<label>Estado</label>
<select v-model="selectedRoute.status" @change="updateRouteDetails">
<option value="active">Activa</option>
<option value="inactive">Inactiva</option>
<option value="maintenance">Mantenimiento</option>
</select>
</div>
</div>
<div class="stops-section">
<h3>Paradas y Horarios</h3>
<div class="add-stop">
<select v-model="newStopId">
<option value="">Selecciona una parada para añadir</option>
<option v-for="stop in availableStops" :key="stop.id" :value="stop.id">
{{ stop.name }}
</option>
</select>
<button @click="addStop" :disabled="!newStopId">Añadir</button>
</div>
<div class="stops-list-editor">
<div class="stops-header">
<span>#</span>
<span>Nombre</span>
<span>Espera (min)</span>
<span>Llegada</span>
<span>Acciones</span>
</div>
<!-- We use computed enriched stops for display, but need to bind inputs to original array or handle updates -->
<div v-for="(stop, index) in routeStops" :key="stop.id" class="stop-item">
<span class="stop-order">{{ index + 1 }}</span>
<span class="stop-name">{{ stop.name }}</span>
<div class="stop-delay">
<input
v-model.number="stop.stop_delay_minutes"
type="number"
min="0"
class="delay-input"
@change="updateStop(stop)"
placeholder="0"
>
</div>
<span class="stop-arrival">
+{{ Math.round(arrivalTimes[index] || 0) }} min
</span>
<div class="stop-actions">
<button @click="moveStop(stop, index, -1)" :disabled="index === 0"></button>
<button @click="moveStop(stop, index, 1)" :disabled="index === routeStops.length - 1"></button>
<button @click="removeStop(stop)" class="remove">×</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
import { routesService } from '@/services/routesService'
import { busStopsService } from '@/services/busStopsService'
import type { Route, BusStop } from '@/types'
const routes = ref<Route[]>([])
const allStops = ref<BusStop[]>([])
const selectedRoute = ref<Route | null>(null)
const routeStops = ref<BusStop[]>([])
const newStopId = ref('')
onMounted(async () => {
routes.value = await routesService.getAllRoutes()
allStops.value = await busStopsService.getAllBusStops()
})
const availableStops = computed(() => {
const currentIds = new Set(routeStops.value.map((s: BusStop) => s.id))
return allStops.value.filter((s: BusStop) => !currentIds.has(s.id))
})
const arrivalTimes = computed(() => {
if (!selectedRoute.value || !routeStops.value.length) return []
// speed is in km/h. Convert to km/min = speed / 60
const speed = selectedRoute.value.average_speed_kmh || 30 // default 30km/h
const speedKmPerMin = speed / 60
const times: number[] = []
let currentTime = 0 // minutes from start
// First stop is at 0
times.push(0)
for (let i = 1; i < routeStops.value.length; i++) {
const prev = routeStops.value[i-1]
const curr = routeStops.value[i]
if (!prev || !curr) continue
// Add delay of previous stop
currentTime += (prev.stop_delay_minutes || 0)
// Calculate travel time
const dist = haversineDistance(prev.latitude, prev.longitude, curr.latitude, curr.longitude)
const travelTime = dist / speedKmPerMin
currentTime += travelTime
times.push(currentTime)
}
return times
})
// Haversine formula for distance in km
function haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
const R = 6371; // Radius of the earth in km
const dLat = deg2rad(lat2 - lat1);
const dLon = deg2rad(lon2 - lon1);
const a =
Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) *
Math.sin(dLon/2) * Math.sin(dLon/2)
;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
const d = R * c; // Distance in km
return d;
}
function deg2rad(deg: number) {
return deg * (Math.PI/180)
}
function translateStatus(status: string) {
const statuses: Record<string, string> = {
'active': 'Activa',
'inactive': 'Inactiva',
'maintenance': 'Mantenimiento'
}
return statuses[status] || status
}
async function createRoute() {
const name = prompt("Introduce el nombre de la ruta")
if (name) {
await routesService.createRoute({
name,
origin_city: 'David',
destination_city: 'Boquete',
status: 'active',
color: '#000000',
direction: 'outbound'
})
routes.value = await routesService.getAllRoutes()
}
}
async function selectRoute(route: Route) {
selectedRoute.value = route
// Ensure route object is reactive for editing (ref is reactive, passing obj by ref is fine)
routeStops.value = await routesService.getRouteStops(route.id)
}
async function updateRouteDetails() {
if (!selectedRoute.value) return
try {
await routesService.updateRoute(selectedRoute.value.id, {
average_speed_kmh: selectedRoute.value.average_speed_kmh,
status: selectedRoute.value.status
})
} catch (e) {
alert('Error al actualizar la ruta')
}
}
async function updateStop(stop: BusStop) {
if (!selectedRoute.value) return
try {
await routesService.updateRouteStop(selectedRoute.value.id, stop.id, {
stop_delay_minutes: stop.stop_delay_minutes || 0
})
} catch (e) {
alert('Error al actualizar el retraso de la parada')
}
}
async function addStop() {
if (!selectedRoute.value || !newStopId.value) return
try {
await routesService.addStopToRoute(selectedRoute.value.id, {
stop_id: newStopId.value,
stop_order: routeStops.value.length + 1
})
routeStops.value = await routesService.getRouteStops(selectedRoute.value.id)
newStopId.value = ''
} catch (e) {
alert('Error al añadir parada')
}
}
async function moveStop(stop: BusStop, index: number, direction: number) {
if (!selectedRoute.value) return
const newOrder = index + 1 + direction
try {
await routesService.updateRouteStop(selectedRoute.value.id, stop.id, {
stop_order: newOrder
})
routeStops.value = await routesService.getRouteStops(selectedRoute.value.id)
} catch (e) {
alert('Error al mover parada')
}
}
async function removeStop(_stop: BusStop) {
alert('Borrar no implementado aún')
}
</script>
<style scoped>
.admin-routes {
padding: 32px 24px;
max-width: 1200px;
margin: 0 auto;
min-height: 100vh;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 48px;
gap: 24px;
}
.back-link {
background: var(--hover-bg);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 12px 20px;
border-radius: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 8px;
}
.back-link:hover {
background: var(--active-bg);
border-color: var(--active-color);
color: var(--active-color);
transform: translateX(-4px);
}
h1 {
font-size: clamp(1.5rem, 4vw, 2.5rem);
font-weight: 900;
margin: 0;
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.04em;
flex: 1;
text-align: center;
}
.add-button {
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
color: #101820;
border: none;
padding: 12px 24px;
border-radius: 14px;
font-weight: 900;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 20px rgba(254, 231, 21, 0.15);
}
.add-button:hover {
transform: translateY(-4px);
box-shadow: 0 15px 30px rgba(254, 231, 21, 0.25);
}
.add-button .material-icons {
font-size: 20px;
}
/* Route List */
.route-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 24px;
}
.route-card {
background: var(--card-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 24px;
padding: 24px;
border: 1px solid var(--border-color);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.route-card:hover {
transform: translateY(-8px);
border-color: var(--active-color);
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
}
.route-info h3 {
margin: 0 0 8px;
font-size: 1.25rem;
font-weight: 900;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.route-info p {
margin: 0 0 16px;
color: var(--text-secondary);
font-weight: 500;
}
.status {
display: inline-flex;
padding: 6px 12px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status.active { background: rgba(34, 197, 94, 0.1); color: #4ade80; border: 1px solid rgba(34, 197, 94, 0.2); }
.status.inactive { background: rgba(239, 68, 68, 0.1); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.2); }
.status.maintenance { background: rgba(245, 158, 11, 0.1); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.2); }
.route-card .material-icons {
color: var(--text-secondary);
transition: transform 0.3s;
}
.route-card:hover .material-icons {
color: var(--active-color);
transform: translateX(4px);
}
/* Editor Styles */
.route-editor {
background: var(--card-bg);
backdrop-filter: blur(16px);
border-radius: 32px;
padding: 40px;
border: 1px solid var(--border-color);
animation: slideIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
}
.editor-header h2 {
font-size: 1.75rem;
font-weight: 900;
color: var(--text-primary);
margin: 0;
}
.editor-header button {
background: var(--hover-bg);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 20px;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.editor-header button:hover {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border-color: #f87171;
}
.route-details-form {
background: var(--bg-secondary);
padding: 24px;
border-radius: 20px;
margin-bottom: 40px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
border: 1px solid var(--border-color);
}
.form-group label {
font-size: 0.85rem;
font-weight: 800;
color: var(--active-color);
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
display: block;
}
.form-group input, .form-group select {
width: 100%;
padding: 14px 18px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 14px;
color: var(--text-primary);
font-size: 1rem;
font-weight: 600;
outline: none;
transition: all 0.3s;
}
.form-group input:focus, .form-group select:focus {
border-color: var(--active-color);
background: rgba(255, 255, 255, 0.08);
}
.stops-section h3 {
font-size: 1.4rem;
font-weight: 900;
margin-bottom: 24px;
}
.add-stop {
display: flex;
gap: 16px;
margin-bottom: 32px;
}
.add-stop select { flex: 1; }
.add-stop button {
background: var(--active-color);
color: #101820;
border: none;
padding: 0 24px;
border-radius: 14px;
font-weight: 900;
cursor: pointer;
transition: all 0.3s;
}
.add-stop button:disabled { opacity: 0.5; cursor: not-allowed; }
.stops-list-editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.stops-header {
display: grid;
grid-template-columns: 60px 1fr 120px 120px 150px;
padding: 12px 24px;
color: var(--text-secondary);
font-weight: 800;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.stop-item {
display: grid;
grid-template-columns: 60px 1fr 120px 120px 150px;
align-items: center;
padding: 20px 24px;
background: var(--bg-secondary);
border-radius: 20px;
gap: 16px;
border: 1px solid var(--border-color);
transition: all 0.3s;
}
.stop-item:hover {
border-color: var(--active-color);
transform: translateX(8px);
}
.stop-order { font-weight: 900; color: var(--active-color); font-size: 1.1rem; }
.stop-name { font-weight: 700; color: var(--text-primary); }
.stop-arrival { font-weight: 800; color: var(--text-secondary); }
.delay-input {
width: 80px !important;
padding: 8px 12px !important;
}
.stop-actions {
display: flex;
gap: 8px;
}
.stop-actions button {
width: 36px;
height: 36px;
border-radius: 10px;
border: 1px solid var(--border-color);
background: var(--hover-bg);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.stop-actions button:hover:not(:disabled) {
background: var(--active-color);
color: #101820;
border-color: var(--active-color);
}
.stop-actions button.remove:hover {
background: #f87171;
border-color: #f87171;
color: white;
}
@media (max-width: 900px) {
.header { flex-direction: column; align-items: stretch; text-align: center; }
.back-link { justify-content: center; }
.add-button { justify-content: center; }
.route-details-form { grid-template-columns: 1fr; }
.stops-header { display: none; }
.stop-item { grid-template-columns: 1fr; text-align: center; justify-items: center; }
.stop-actions { margin-top: 12px; }
}
</style>

View File

@ -0,0 +1,613 @@
<template>
<div class="admin-schedules">
<div class="glass-container">
<div class="header">
<button class="back-link" @click="router.push('/admin')">
<span class="material-icons">arrow_back</span>
Volver al Panel
</button>
<h1 class="premium-title">Gestión de Horarios</h1>
</div>
<!-- Route Selection -->
<div class="selection-card glass-morphism">
<div class="input-w-icon">
<span class="material-icons">route</span>
<select v-model="selectedRouteId" @change="loadSchedules" class="premium-select">
<option value="">-- Selecciona una ruta para gestionar --</option>
<option v-for="route in routes" :key="route.id" :value="route.id">
{{ route.name }} ({{ route.origin_city }} &rarr; {{ route.destination_city }})
</option>
</select>
</div>
</div>
<div v-if="selectedRouteId" class="schedules-content">
<div class="actions-header">
<h2 class="section-title">Horarios Configurados</h2>
<button class="add-btn premium-btn" @click="showAddForm = true">
<span class="material-icons">add</span> Nuevo Horario
</button>
</div>
<!-- Add/Edit form -->
<Transition name="fade">
<div v-if="showAddForm || editingSchedule" class="schedule-form-card glass-morphism active-border">
<h3 class="form-title">{{ editingSchedule ? 'Editar Horario' : 'Agregar Nuevo Horario' }}</h3>
<div class="form-grid">
<div class="form-group">
<label>Hora de Salida</label>
<div class="input-wrapper">
<span class="material-icons">schedule</span>
<input v-model="form.departure_time" type="time" required>
</div>
</div>
<div class="form-group">
<label>Frecuencia (min)</label>
<div class="input-wrapper">
<span class="material-icons">update</span>
<input v-model.number="form.frequency_minutes" type="number" placeholder="30">
</div>
</div>
<div class="form-group">
<label>Tipo de Día</label>
<div class="input-wrapper">
<span class="material-icons">calendar_today</span>
<select v-model="form.schedule_type">
<option value="weekday">Día de Semana</option>
<option value="weekend">Fin de Semana</option>
<option value="holiday">Feriado</option>
</select>
</div>
</div>
<div class="form-group toggle-group">
<label class="switch-label">
<span>Publicado</span>
<input v-model="form.is_published" type="checkbox" class="retro-checkbox">
</label>
</div>
<div class="form-group toggle-group">
<label class="switch-label">
<span>Activo (Operativo)</span>
<input v-model="form.is_active" type="checkbox" class="retro-checkbox">
</label>
</div>
</div>
<div class="form-actions">
<button class="btn-secondary" @click="cancelForm">Cancelar</button>
<button class="btn-primary" @click="saveSchedule">Guardar Horario</button>
</div>
</div>
</Transition>
<!-- Schedules List -->
<div class="schedules-list">
<div v-if="isLoadingSchedules" class="loader-container">
<span class="material-icons spin">refresh</span>
<p>Cargando horarios...</p>
</div>
<div v-else-if="schedules.length === 0" class="empty-state glass-morphism">
<span class="material-icons">event_busy</span>
<p>No hay horarios configurados para esta ruta.</p>
</div>
<div v-else class="grid-container">
<div
v-for="schedule in sortedSchedules"
:key="schedule.id"
class="schedule-card-premium glass-morphism"
:class="{ 'draft-card': !schedule.is_published }"
>
<div class="card-left">
<div class="time-display">{{ formatTo12Hour(schedule.departure_time) }}</div>
<div class="type-tag" :class="schedule.schedule_type">
{{ translateType(schedule.schedule_type) }}
</div>
</div>
<div class="card-right">
<div class="status-indicator">
<span class="dot" :class="{ 'online': schedule.is_published }"></span>
{{ schedule.is_published ? 'Publicado' : 'Borrador' }}
</div>
<div class="action-buttons">
<button class="icon-btn edit-btn" @click="editSchedule(schedule)" title="Editar">
<span class="material-icons">edit</span>
</button>
<button class="icon-btn delete-btn" @click="handleDelete(schedule.id)" title="Eliminar">
<span class="material-icons">delete</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="no-selection-state">
<div class="icon-circle">
<span class="material-icons">list_alt</span>
</div>
<h3>Gestión de Despachos</h3>
<p>Selecciona una ruta del menú superior para administrar los horarios de salida y frecuencia.</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { routesService } from '@/services/routesService'
import { schedulesService } from '@/services/schedulesService'
import { formatTo12Hour } from '@/utils/timeFormatter'
const router = useRouter()
const routes = ref<any[]>([])
const selectedRouteId = ref('')
const schedules = ref<any[]>([])
const showAddForm = ref(false)
const editingSchedule = ref<any>(null)
const isLoadingSchedules = ref(false)
const form = ref({
departure_time: '06:00',
frequency_minutes: 30,
schedule_type: 'weekday',
is_published: true,
is_active: true
})
onMounted(async () => {
try {
routes.value = await routesService.getAllRoutes()
} catch (e) {
console.error('Error loading routes', e)
}
})
const sortedSchedules = computed(() => {
return [...schedules.value].sort((a, b) => a.departure_time.localeCompare(b.departure_time))
})
async function loadSchedules() {
if (!selectedRouteId.value) {
schedules.value = []
return
}
isLoadingSchedules.value = true
try {
// Get all schedules including drafts (false for onlyPublished)
schedules.value = await schedulesService.getRouteSchedules(selectedRouteId.value, false)
} catch (e) {
console.error('Error loading schedules', e)
} finally {
isLoadingSchedules.value = false
}
}
function translateType(type: string) {
const map: Record<string, string> = {
'weekday': 'Día de Semana',
'weekend': 'Fin de Semana',
'holiday': 'Feriado'
}
return map[type] || type
}
function editSchedule(schedule: any) {
editingSchedule.value = schedule
form.value = {
departure_time: schedule.departure_time,
frequency_minutes: schedule.frequency_minutes,
schedule_type: schedule.schedule_type,
is_published: schedule.is_published,
is_active: schedule.is_active
}
showAddForm.value = true
}
function cancelForm() {
showAddForm.value = false
editingSchedule.value = null
resetForm()
}
function resetForm() {
form.value = {
departure_time: '06:00',
frequency_minutes: 30,
schedule_type: 'weekday',
is_published: true,
is_active: true
}
}
async function saveSchedule() {
try {
if (editingSchedule.value) {
await schedulesService.updateSchedule(editingSchedule.value.id, form.value)
} else {
await schedulesService.createSchedule({
...form.value,
route_id: selectedRouteId.value
})
}
await loadSchedules()
cancelForm()
} catch (e: any) {
console.error('Save error details:', e.response?.data || e)
const errorMsg = e.response?.data?.detail
? (typeof e.response.data.detail === 'string' ? e.response.data.detail : JSON.stringify(e.response.data.detail))
: 'Error de conexión con el servidor'
alert('Error al guardar: ' + errorMsg)
}
}
async function handleDelete(id: string) {
if (!confirm('¿Estás seguro de eliminar este horario?')) return
try {
await schedulesService.deleteSchedule(id)
await loadSchedules()
} catch (e: any) {
console.error('Delete error:', e.response?.data || e)
const errorMsg = e.response?.data?.detail
? (typeof e.response.data.detail === 'string' ? e.response.data.detail : JSON.stringify(e.response.data.detail))
: 'Error al eliminar el registro'
alert('Error: ' + errorMsg)
}
}
</script>
<style scoped>
.admin-schedules {
padding: 40px 20px;
min-height: 100vh;
background: var(--bg-primary);
}
.glass-container {
max-width: 1000px;
margin: 0 auto;
}
.header {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 32px;
}
.back-link {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 16px;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
width: fit-content;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.back-link:hover {
transform: translateX(-4px);
background: var(--hover-bg);
border-color: var(--active-color);
}
.premium-title {
font-size: 2.5rem;
font-weight: 900;
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.04em;
}
.glass-morphism {
background: var(--card-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border-color);
border-radius: 20px;
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
}
.selection-card {
padding: 24px;
margin-bottom: 40px;
}
.input-w-icon {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-secondary);
padding: 12px 16px;
border-radius: 14px;
border: 1px solid var(--border-color);
}
.input-w-icon .material-icons {
color: var(--active-color);
}
.premium-select {
flex: 1;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 1.1rem;
font-weight: 600;
outline: none;
cursor: pointer;
}
.actions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.section-title {
font-size: 1.5rem;
font-weight: 800;
color: var(--text-primary);
}
.premium-btn {
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
color: #101820;
border: none;
padding: 12px 24px;
border-radius: 14px;
font-weight: 800;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 20px rgba(254, 231, 21, 0.2);
}
.premium-btn:hover {
transform: translateY(-4px);
box-shadow: 0 15px 30px rgba(254, 231, 21, 0.3);
}
.schedule-form-card {
padding: 32px;
margin-bottom: 32px;
}
.active-border {
border-color: var(--active-color);
}
.form-title {
margin-bottom: 24px;
font-size: 1.25rem;
font-weight: 800;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.form-group label {
display: block;
font-size: 0.9rem;
font-weight: 700;
color: var(--text-secondary);
margin-bottom: 8px;
}
.input-wrapper {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-secondary);
padding: 10px 14px;
border-radius: 10px;
border: 1.5px solid var(--border-color);
}
.input-wrapper input, .input-wrapper select {
background: transparent;
border: none;
color: var(--text-primary);
font-weight: 600;
width: 100%;
outline: none;
}
.switch-label {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
}
.retro-checkbox {
width: 20px;
height: 20px;
accent-color: var(--active-color);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 16px;
}
.btn-primary {
background: var(--active-color);
color: #101820;
border: none;
padding: 12px 28px;
border-radius: 12px;
font-weight: 800;
cursor: pointer;
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
padding: 12px 28px;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
}
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.schedule-card-premium {
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.3s ease;
}
.schedule-card-premium:hover {
transform: scale(1.02);
border-color: var(--active-color);
}
.draft-card {
opacity: 0.6;
background: var(--bg-secondary);
}
.time-display {
font-size: 1.75rem;
font-weight: 900;
color: var(--text-primary);
margin-bottom: 8px;
}
.type-tag {
font-size: 0.75rem;
font-weight: 800;
padding: 4px 10px;
border-radius: 6px;
display: inline-block;
}
.type-tag.weekday { background: rgba(52, 152, 219, 0.1); color: #3498db; }
.type-tag.weekend { background: rgba(155, 89, 182, 0.1); color: #9b59b2; }
.type-tag.holiday { background: rgba(231, 76, 60, 0.1); color: #e74c3c; }
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
font-weight: 700;
color: var(--text-secondary);
margin-bottom: 12px;
justify-content: flex-end;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #95a5a6;
}
.dot.online {
background: #2ecc71;
box-shadow: 0 0 8px #2ecc71;
}
.action-buttons {
display: flex;
gap: 8px;
}
.icon-btn {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.edit-btn:hover { background: var(--hover-bg); color: var(--active-color); border-color: var(--active-color); }
.delete-btn:hover { background: #fff0f0; color: #e74c3c; border-color: #ef4444; }
.no-selection-state {
text-align: center;
padding: 80px 40px;
}
.icon-circle {
width: 100px;
height: 100px;
background: var(--bg-secondary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.icon-circle .material-icons {
font-size: 3rem;
color: var(--active-color);
opacity: 0.5;
}
.no-selection-state h3 {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 12px;
}
.no-selection-state p {
color: var(--text-secondary);
max-width: 400px;
margin: 0 auto;
}
.loader-container {
text-align: center;
padding: 40px;
}
.spin {
animation: spin 1s linear infinite;
display: block;
margin: 0 auto 12px;
font-size: 2rem;
color: var(--active-color);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@ -0,0 +1,701 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { API_URL } from '@/services/apiClient';
import axios from 'axios';
const router = useRouter();
const isLoading = ref(false);
const showMessage = ref({ text: '', type: '' });
const selectedFile = ref<File | null>(null);
const selectedFileName = ref('');
// Form state
const shuttleForm = ref({
company_name: 'Chiriqui Transfers',
origin: 'Boquete',
destination: 'Santa Catalina',
vehicle_type: 'Mini Van Compartida',
price_per_person: 35,
price_private_trip: 180,
estimated_duration: '4.5 horas',
departure_times: 'Todos los días 8:00 AM',
contact_whatsapp: '50712345678',
phone_number: '50712345678',
english_speaking: true,
image_url: '',
is_active: true
});
const previewImageUrl = ref('https://images.unsplash.com/photo-1449034446853-66c86144b0ad?q=80&w=2070&auto=format&fit=crop');
function handleImageChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files[0]) {
const file = input.files[0];
selectedFile.value = file;
selectedFileName.value = file.name;
// Preview logic
const reader = new FileReader();
reader.onload = (e) => {
previewImageUrl.value = e.target?.result as string;
};
reader.readAsDataURL(file);
}
}
async function saveShuttle() {
isLoading.value = true;
showMessage.value = { text: '', type: '' };
try {
const token = localStorage.getItem('auth_token');
const formData = new FormData();
// Añadimos campos obligatorios para el backend
formData.append('route_name', `${shuttleForm.value.origin} - ${shuttleForm.value.destination}`);
formData.append('origin', shuttleForm.value.origin);
formData.append('destination', shuttleForm.value.destination);
formData.append('vehicle_type', shuttleForm.value.vehicle_type);
formData.append('company_name', shuttleForm.value.company_name);
formData.append('price_per_person', String(shuttleForm.value.price_per_person));
formData.append('price_private_trip', String(shuttleForm.value.price_private_trip));
formData.append('estimated_duration', shuttleForm.value.estimated_duration);
formData.append('departure_times', shuttleForm.value.departure_times);
formData.append('contact_whatsapp', shuttleForm.value.contact_whatsapp);
formData.append('phone_number', shuttleForm.value.phone_number);
formData.append('english_speaking', String(shuttleForm.value.english_speaking));
if (selectedFile.value) {
formData.append('image', selectedFile.value);
}
await axios.post(`${API_URL}/api/shuttles`, formData, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'multipart/form-data'
}
});
showMessage.value = { text: '¡Viaje Turístico Desplegado!', type: 'success' };
setTimeout(() => router.push('/admin'), 2000);
} catch (error: any) {
console.error('Error saving shuttle:', error);
const errorDetail = error.response?.data?.detail || 'Error en el despliegue del sistema.';
showMessage.value = { text: errorDetail, type: 'error' };
} finally {
isLoading.value = false;
}
}
</script>
<template>
<div class="admin-shuttles-view">
<div class="nexus-admin-header">
<button class="back-btn" @click="router.push('/admin')">
<span class="material-icons">arrow_back</span>
</button>
<h1>Generador de Shuttles Turísticos</h1>
<div class="header-status">ID: SHUTTLE-MARK-I</div>
</div>
<div class="admin-grid-layout">
<!-- FORM PANEL -->
<section class="form-panel nexus-glass">
<div class="section-title">
<span class="material-icons">edit_note</span>
<h2>Datos del Servicio</h2>
</div>
<div class="nexus-form">
<div class="form-group grid-row">
<div class="input-box">
<label>Nombre de la Empresa</label>
<input v-model="shuttleForm.company_name" type="text" placeholder="Ej: Chiriqui Transfers">
</div>
<div class="input-box">
<label>Imagen del Transporte</label>
<div class="file-upload-wrapper">
<input type="file" @change="handleImageChange" accept="image/*" id="file-input">
<label for="file-input" class="file-label">
<span class="material-icons">cloud_upload</span>
{{ selectedFileName || 'SELECCIONAR IMAGEN' }}
</label>
<p class="upload-hint">Recomendado: 1200x900px (4:3)</p>
</div>
</div>
</div>
<div class="form-group grid-row">
<div class="input-box">
<label>Origen</label>
<input v-model="shuttleForm.origin" type="text" placeholder="Boquete">
</div>
<div class="input-box">
<label>Destino</label>
<input v-model="shuttleForm.destination" type="text" placeholder="Santa Catalina">
</div>
</div>
<div class="form-group">
<label>Tipo de Vehículo</label>
<input v-model="shuttleForm.vehicle_type" type="text" placeholder="Mini Van Compartida">
</div>
<div class="form-group grid-row">
<div class="input-box">
<label>Duración Estimada</label>
<input v-model="shuttleForm.estimated_duration" type="text" placeholder="4.5 horas">
</div>
<div class="input-box">
<label>Salidas</label>
<input v-model="shuttleForm.departure_times" type="text" placeholder="Todos los días 8:00 AM">
</div>
</div>
<div class="form-group grid-row">
<div class="input-box">
<label>Precio por Persona ($)</label>
<input v-model="shuttleForm.price_per_person" type="number">
</div>
<div class="input-box">
<label>Precio Viaje Privado ($)</label>
<input v-model="shuttleForm.price_private_trip" type="number">
</div>
</div>
<div class="form-group grid-row">
<div class="input-box">
<label>WhatsApp (Sin +)</label>
<div class="whatsapp-input">
<span class="prefix">+</span>
<input v-model="shuttleForm.contact_whatsapp" type="text" placeholder="50760000000">
</div>
</div>
<div class="input-box">
<label>Teléfono de Llamada</label>
<input v-model="shuttleForm.phone_number" type="text" placeholder="50760000000">
</div>
</div>
<div class="form-group">
<label class="toggle-container">
<div class="toggle-text">
<span class="material-icons">translate</span>
<span>¿Habla Inglés? (Bilingüe)</span>
</div>
<div class="nexus-switch">
<input type="checkbox" v-model="shuttleForm.english_speaking">
<span class="slider"></span>
</div>
</label>
</div>
<button class="deploy-btn" :disabled="isLoading" @click="saveShuttle">
<span class="material-icons">{{ isLoading ? 'sync' : 'rocket_launch' }}</span>
{{ isLoading ? 'PROCESANDO...' : 'PUBLICAR EN SIBU' }}
</button>
<p v-if="showMessage.text" :class="['message', showMessage.type]">{{ showMessage.text }}</p>
</div>
</section>
<!-- PREVIEW PANEL -->
<section class="preview-panel">
<div class="section-title white">
<span class="material-icons">visibility</span>
<h2>Previsualización en Directo</h2>
</div>
<div class="preview-container">
<!-- LA TARJETA TAL CUAL LA IMAGEN DEL USUARIO -->
<div class="shuttle-card-preview" :style="{ backgroundImage: `url(${shuttleForm.image_url})` }">
<div class="card-header">
<div class="company-badge">
<span class="material-icons">business</span>
{{ shuttleForm.company_name }}
</div>
<div class="price-badge-top">
${{ shuttleForm.price_per_person }}
</div>
</div>
<div class="route-display">
<span class="city">{{ shuttleForm.origin }}</span>
<span class="material-icons arrow">arrow_forward</span>
<span class="city">{{ shuttleForm.destination }}</span>
</div>
<div class="vehicle-tag">
<span class="material-icons">directions_bus</span>
{{ shuttleForm.vehicle_type }}
</div>
<div class="card-details-box">
<div class="detail-item">
<span class="material-icons icon-yellow">schedule</span>
<div class="texts">
<span class="label">DURACIÓN ESTIMADA</span>
<span class="val">{{ shuttleForm.estimated_duration }}</span>
</div>
</div>
<div class="detail-item">
<span class="material-icons icon-yellow">calendar_today</span>
<div class="texts">
<span class="label">SALIDAS</span>
<span class="val">{{ shuttleForm.departure_times }}</span>
</div>
</div>
</div>
<div class="card-footer-preview">
<div class="price-info">
<span class="main-price">${{ shuttleForm.price_per_person }} <small>por persona</small></span>
<div class="lang-indicator" v-if="shuttleForm.english_speaking">
<span class="material-icons">g_translate</span>
ENGLISH
</div>
</div>
<div class="contact-actions">
<div class="mini-contact-btn phone">
<span class="material-icons">phone</span>
</div>
<div class="mini-contact-btn wa">
<span class="material-icons">chat</span>
</div>
</div>
</div>
</div>
<p class="preview-hint">Esta es la apariencia final que verán los usuarios en su app móvil.</p>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.admin-shuttles-view {
min-height: 100vh;
background: #0f172a;
color: white;
padding: 40px;
font-family: 'Inter', sans-serif;
}
.nexus-admin-header {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 40px;
}
.back-btn {
background: rgba(255,255,255,0.1);
border: none;
color: white;
width: 44px;
height: 44px;
border-radius: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.nexus-admin-header h1 {
font-size: 2rem;
font-weight: 800;
margin: 0;
background: linear-gradient(135deg, #fff 0%, #94a3b8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-status {
padding: 4px 12px;
background: rgba(254, 231, 21, 0.1);
color: #fee715;
border: 1px solid rgba(254, 231, 21, 0.2);
border-radius: 20px;
font-size: 0.75rem;
font-weight: 800;
margin-left: auto;
}
.admin-grid-layout {
display: grid;
grid-template-columns: 1fr 450px;
gap: 40px;
}
.nexus-glass {
background: rgba(30, 41, 59, 0.5);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 32px;
padding: 32px;
}
.section-title {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 32px;
color: #94a3b8;
}
.section-title h2 {
font-size: 1.25rem;
margin: 0;
}
.section-title.white {
color: white;
}
.nexus-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.grid-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 0.85rem;
font-weight: 600;
color: #94a3b8;
}
.form-group input {
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 14px 16px;
border-radius: 12px;
color: white;
font-size: 1rem;
outline: none;
transition: all 0.3s;
}
.form-group input:focus {
border-color: #fee715;
box-shadow: 0 0 0 4px rgba(254, 231, 21, 0.1);
}
.file-upload-wrapper {
position: relative;
width: 100%;
}
.file-upload-wrapper input[type="file"] {
position: absolute;
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
z-index: -1;
}
.file-label {
display: flex !important;
align-items: center;
justify-content: center;
gap: 10px;
background: rgba(254, 231, 21, 0.1) !important;
border: 1px dashed #fee715 !important;
padding: 12px !important;
border-radius: 12px;
color: #fee715 !important;
font-weight: 800;
cursor: pointer;
transition: all 0.3s;
text-transform: uppercase;
font-size: 0.75rem !important;
}
.file-label:hover {
background: rgba(254, 231, 21, 0.2) !important;
}
.upload-hint {
font-size: 0.7rem;
color: #94a3b8;
margin-top: 6px;
font-weight: 600;
text-align: right;
}
.whatsapp-input {
display: flex;
background: rgba(15, 23, 42, 0.5);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.whatsapp-input .prefix {
padding: 14px 16px;
background: rgba(255,255,255,0.05);
color: #94a3b8;
font-weight: 700;
}
.whatsapp-input input {
flex: 1;
border: none;
background: transparent;
}
.deploy-btn {
margin-top: 20px;
background: #fee715;
color: #101820;
border: none;
padding: 20px;
border-radius: 16px;
font-weight: 800;
font-size: 1.1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
transition: all 0.3s;
}
.deploy-btn:hover:not(:disabled) {
transform: translateY(-4px);
box-shadow: 0 10px 20px rgba(254, 231, 21, 0.2);
}
.deploy-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* SHUTTLE PREVIEW CARD STYLES */
.shuttle-card-preview {
width: 100%;
aspect-ratio: 1.2;
background-size: cover;
background-position: center;
border-radius: 24px;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 20px;
box-shadow: 0 30px 60px rgba(0,0,0,0.5);
}
.shuttle-card-preview::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.2) 60%, rgba(0,0,0,0.4) 100%);
}
.card-header, .route-display, .vehicle-tag, .card-details-box, .card-footer-preview {
position: relative;
z-index: 1;
}
.company-badge {
background: rgba(254, 231, 21, 0.2);
backdrop-filter: blur(8px);
color: #fee715;
padding: 6px 12px;
border-radius: 8px;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
font-weight: 800;
border: 1px solid rgba(254, 231, 21, 0.3);
}
.price-badge-top {
position: absolute;
top: 0;
right: 0;
background: #fee715;
color: #101820;
padding: 8px 16px;
border-radius: 12px;
font-weight: 900;
font-size: 1.1rem;
}
.route-display {
margin-top: 20px;
display: flex;
align-items: center;
gap: 12px;
font-size: 1.5rem;
font-weight: 900;
}
.route-display .arrow {
color: #fee715;
}
.vehicle-tag {
margin-top: 12px;
background: rgba(0,0,0,0.6);
padding: 8px 16px;
border-radius: 12px;
display: inline-flex;
align-items: center;
gap: 10px;
width: fit-content;
font-weight: 700;
font-size: 0.9rem;
}
.card-details-box {
margin-top: auto;
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-item {
display: flex;
align-items: center;
gap: 12px;
}
.icon-yellow { color: #fee715; font-size: 20px; }
.lang-indicator {
display: flex;
align-items: center;
gap: 4px;
background: rgba(254, 231, 21, 0.2);
color: #fee715;
padding: 2px 8px;
border-radius: 6px;
font-size: 0.6rem;
font-weight: 800;
margin-top: 4px;
width: fit-content;
}
.lang-indicator .material-icons { font-size: 10px; }
.card-footer-preview {
display: flex;
align-items: flex-end;
justify-content: space-between;
width: 100%;
}
.contact-actions {
display: flex;
gap: 12px;
margin-left: auto;
}
.mini-contact-btn {
width: 50px;
height: 50px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.mini-contact-btn.phone { background: rgba(255,255,255,0.1); color: white; border: 1px solid rgba(255,255,255,0.2); }
.mini-contact-btn.wa { background: #25d366; color: white; box-shadow: 0 4px 12px rgba(37, 211, 102, 0.3); }
.mini-contact-btn .material-icons { font-size: 24px; }
.toggle-container {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(15, 23, 42, 0.5);
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
}
.toggle-text {
display: flex;
align-items: center;
gap: 12px;
color: #94a3b8;
font-weight: 600;
}
.nexus-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.nexus-switch input { opacity: 0; width: 0; height: 0; }
.nexus-switch .slider {
position: absolute;
cursor: pointer;
inset: 0;
background-color: rgba(255,255,255,0.1);
transition: .4s;
border-radius: 34px;
}
.nexus-switch .slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
.nexus-switch input:checked + .slider { background-color: #fee715; }
.nexus-switch input:checked + .slider:before { transform: translateX(20px); background-color: #101820; }
.preview-hint {
text-align: center;
color: #94a3b8;
font-size: 0.85rem;
margin-top: 20px;
}
.message {
text-align: center;
padding: 12px;
border-radius: 12px;
font-weight: 700;
}
.message.success { background: rgba(37, 211, 102, 0.1); color: #25d366; }
.message.error { background: rgba(239, 68, 68, 0.1); color: #ef4444; }
@media (max-width: 1100px) {
.admin-grid-layout { grid-template-columns: 1fr; }
.preview-panel { order: -1; }
}
</style>

View File

@ -0,0 +1,716 @@
<template>
<div class="admin-taxis">
<div class="header">
<button class="back-link" @click="$router.push('/admin')"> Volver al Panel</button>
<h1>Directorio de Taxis</h1>
<button class="btn-primary" @click="openModal()">
<span class="material-icons">add</span>
Nuevo Taxi
</button>
</div>
<div v-if="isLoading" class="loading">Cargando directorio...</div>
<div v-else class="taxis-list">
<div v-if="taxis.length > 0" class="taxis-grid">
<div v-for="taxi in taxis" :key="taxi.id" class="taxi-card">
<div class="card-header">
<div class="taxi-info">
<div class="avatar">
<img v-if="taxi.image_url" :src="getImageUrl(taxi.image_url)" alt="Taxi">
<span v-else class="material-icons">local_taxi</span>
</div>
<div>
<h3>{{ taxi.owner_name }}</h3>
<p class="plate">{{ taxi.license_plate }}</p>
<p class="phone">{{ taxi.phone_number }}</p>
</div>
</div>
<div class="card-actions">
<button class="btn-icon" @click="openModal(taxi)" title="Editar">
<span class="material-icons">edit</span>
</button>
<button class="btn-icon delete" @click="deleteTaxi(taxi)" title="Eliminar">
<span class="material-icons">delete</span>
</button>
</div>
</div>
<div class="card-body">
<div class="info-row">
<span class="label">Zona:</span>
<span>{{ taxi.corregimiento }}</span>
</div>
<div class="info-row">
<span class="label">Horario:</span>
<span>{{ getShiftLabel(taxi.shift) }}</span>
</div>
<div class="info-row" v-if="taxi.cooperative">
<span class="label">Cooperativa:</span>
<span>{{ taxi.cooperative }}</span>
</div>
<div class="info-row">
<span class="label">Rating:</span>
<span class="rating">{{ taxi.rating || 5.0 }} </span>
</div>
<div class="info-row">
<span class="label">Inglés:</span>
<span>{{ taxi.english_speaking ? 'Sí' : 'No' }}</span>
</div>
<div class="info-row">
<span class="label">Estado:</span>
<span :class="taxi.is_active ? 'status-active' : 'status-inactive'">
{{ taxi.is_active ? 'Activo' : 'Inactivo' }}
</span>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<span class="material-icons">local_taxi</span>
<p>No hay taxis registrados en el directorio</p>
</div>
</div>
<!-- Modal for Create/Edit -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h2>{{ editingTaxi ? 'Editar Taxi' : 'Nuevo Taxi' }}</h2>
<button @click="closeModal">&times;</button>
</div>
<div class="modal-body">
<form @submit.prevent="saveTaxi">
<div class="form-grid">
<div class="form-group">
<label>Nombre del Conductor *</label>
<input v-model="taxiForm.owner_name" type="text" placeholder="Juan Pérez" required>
</div>
<div class="form-group">
<label>Teléfono *</label>
<input v-model="taxiForm.phone_number" type="tel" placeholder="+507 1234-5678" required>
</div>
<div class="form-group">
<label>Placa del Vehículo *</label>
<input v-model="taxiForm.license_plate" type="text" placeholder="CHI-1234" required>
</div>
<div class="form-group">
<label>Zona de Servicio *</label>
<select v-model="taxiForm.corregimiento" required>
<option value="">Seleccionar...</option>
<option value="Boquete">Boquete</option>
<option value="David - Boquete">David - Boquete</option>
<option value="Boquete - David">Boquete - David</option>
<option value="Aeropuerto - Boquete">Aeropuerto - Boquete</option>
</select>
</div>
<div class="form-group">
<label>Horario *</label>
<select v-model="taxiForm.shift" required>
<option value="">Seleccionar...</option>
<option value="dia">Día</option>
<option value="tarde">Tarde</option>
<option value="noche">Noche</option>
</select>
</div>
<div class="form-group">
<label>Cooperativa</label>
<input v-model="taxiForm.cooperative" type="text" placeholder="Cooperativa Boquete">
</div>
<div class="form-group">
<label>Rating (1-5)</label>
<input v-model.number="taxiForm.rating" type="number" min="1" max="5" step="0.1" placeholder="5.0">
</div>
<div class="form-group checkbox-group">
<label>
<input v-model="taxiForm.english_speaking" type="checkbox">
<span>Habla Inglés</span>
</label>
</div>
<div class="form-group checkbox-group">
<label>
<input v-model="taxiForm.is_active" type="checkbox">
<span>Activo en el directorio</span>
</label>
</div>
<div class="form-group full-width">
<label>Foto del Conductor</label>
<input type="file" @change="handleFileChange" accept="image/*">
<small>Opcional - Foto para el directorio público</small>
</div>
</div>
<p v-if="error" class="error-text">{{ error }}</p>
<div class="form-actions">
<button type="button" class="btn-secondary" @click="closeModal">Cancelar</button>
<button type="submit" class="btn-primary" :disabled="isSaving">
{{ isSaving ? 'Guardando...' : 'Guardar' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue'
import { apiClient, API_URL } from '@/services/apiClient'
const isLoading = ref(false)
const taxis = ref<any[]>([])
const showModal = ref(false)
const editingTaxi = ref<any>(null)
const isSaving = ref(false)
const error = ref('')
const photoFile = ref<File | null>(null)
const taxiForm = reactive({
owner_name: '',
phone_number: '',
license_plate: '',
corregimiento: '',
shift: '',
cooperative: '',
rating: 5.0,
english_speaking: false,
is_active: true
})
onMounted(() => {
loadTaxis()
})
async function loadTaxis() {
isLoading.value = true
try {
const response = await apiClient.get('/api/taxis', {
params: { is_active: undefined } // Get all taxis
})
taxis.value = response.data
} catch (e) {
console.error('Error loading taxis:', e)
} finally {
isLoading.value = false
}
}
function openModal(taxi?: any) {
if (taxi) {
editingTaxi.value = taxi
Object.assign(taxiForm, {
owner_name: taxi.owner_name,
phone_number: taxi.phone_number,
license_plate: taxi.license_plate,
corregimiento: taxi.corregimiento,
shift: taxi.shift,
cooperative: taxi.cooperative || '',
rating: taxi.rating || 5.0,
english_speaking: taxi.english_speaking || false,
is_active: taxi.is_active
})
} else {
editingTaxi.value = null
Object.assign(taxiForm, {
owner_name: '',
phone_number: '',
license_plate: '',
corregimiento: '',
shift: '',
cooperative: '',
rating: 5.0,
english_speaking: false,
is_active: true
})
}
photoFile.value = null
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingTaxi.value = null
photoFile.value = null
}
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
photoFile.value = target.files[0]
}
}
async function saveTaxi() {
isSaving.value = true
error.value = ''
try {
const formData = new FormData()
formData.append('owner_name', taxiForm.owner_name)
formData.append('phone_number', taxiForm.phone_number)
formData.append('license_plate', taxiForm.license_plate)
formData.append('corregimiento', taxiForm.corregimiento)
formData.append('shift', taxiForm.shift)
formData.append('rating', String(taxiForm.rating))
formData.append('english_speaking', String(taxiForm.english_speaking))
formData.append('is_active', String(taxiForm.is_active))
if (taxiForm.cooperative) formData.append('cooperative', taxiForm.cooperative)
if (photoFile.value) formData.append('image', photoFile.value)
if (editingTaxi.value) {
// Update existing taxi
await apiClient.put(`/api/taxis/${editingTaxi.value.id}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
} else {
// Create new taxi
await apiClient.post('/api/taxis', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
closeModal()
await loadTaxis()
} catch (e: any) {
error.value = e.response?.data?.detail || 'Error al guardar el taxi'
console.error('Error saving taxi:', e)
} finally {
isSaving.value = false
}
}
async function deleteTaxi(taxi: any) {
if (!confirm(`¿Eliminar a ${taxi.owner_name} del directorio?`)) return
try {
await apiClient.delete(`/api/taxis/${taxi.id}`)
await loadTaxis()
} catch (e) {
alert('Error al eliminar el taxi')
console.error('Error deleting taxi:', e)
}
}
function getShiftLabel(shift: string) {
const labels: Record<string, string> = {
'dia': 'Día',
'tarde': 'Tarde',
'noche': 'Noche'
}
return labels[shift] || shift
}
function getImageUrl(path: string) {
if (path.startsWith('http')) return path
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
}
</script>
<style scoped>
.admin-taxis {
padding: 24px;
background: var(--bg-primary);
min-height: 100vh;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
gap: 16px;
}
.back-link {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
padding: 10px 16px;
border-radius: 8px;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 4px;
}
.back-link:hover {
background: var(--hover-bg);
border-color: var(--accent-color);
color: var(--accent-color);
transform: translateX(-2px);
}
h1 {
flex: 1;
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
margin: 0;
}
.btn-primary {
background: var(--accent-color);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.2s;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading, .empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state .material-icons {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.3;
}
.taxis-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.taxi-card {
background: var(--card-bg);
border-radius: 16px;
padding: 20px;
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px var(--shadow);
transition: transform 0.2s, box-shadow 0.2s;
}
.taxi-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px var(--shadow);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.taxi-info {
display: flex;
gap: 12px;
flex: 1;
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
overflow: hidden;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar .material-icons {
font-size: 32px;
color: var(--text-secondary);
}
.taxi-info h3 {
margin: 0 0 4px;
font-size: 1.1rem;
font-weight: 700;
color: var(--text-primary);
}
.plate {
font-family: 'Courier New', monospace;
background: var(--bg-secondary);
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
font-size: 0.9rem;
margin: 4px 0;
color: var(--text-primary);
}
.phone {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 4px 0 0;
}
.card-actions {
display: flex;
gap: 8px;
}
.btn-icon {
background: transparent;
border: 1px solid var(--border-color);
width: 36px;
height: 36px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
color: var(--text-secondary);
}
.btn-icon:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
.btn-icon.delete:hover {
background: #fee;
border-color: #f44;
color: #f44;
}
.card-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.info-row {
display: flex;
justify-content: space-between;
font-size: 0.9rem;
}
.info-row .label {
color: var(--text-secondary);
font-weight: 500;
}
.rating {
color: #fee715;
font-weight: 600;
}
.status-active {
color: var(--accent-color);
font-weight: 600;
}
.status-inactive {
color: var(--text-secondary);
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: var(--card-bg);
border-radius: 16px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
color: var(--text-primary);
}
.modal-header button {
background: transparent;
border: none;
font-size: 2rem;
cursor: pointer;
color: var(--text-secondary);
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s;
}
.modal-header button:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
.modal-body {
padding: 24px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group.checkbox-group {
flex-direction: row;
align-items: center;
}
.form-group.checkbox-group label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.form-group label {
font-weight: 600;
color: var(--text-primary);
font-size: 0.9rem;
}
.form-group input[type="text"],
.form-group input[type="tel"],
.form-group input[type="number"],
.form-group select {
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-color);
}
.form-group input[type="file"] {
padding: 8px;
border: 1px dashed var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
}
.form-group small {
color: var(--text-secondary);
font-size: 0.85rem;
}
.error-text {
color: #f44;
margin: 16px 0;
padding: 12px;
background: #fee;
border-radius: 8px;
font-size: 0.9rem;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
background: var(--hover-bg);
}
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.taxis-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref } from 'vue'
import LoginForm from '@/components/auth/LoginForm.vue'
import RegisterForm from '@/components/auth/RegisterForm.vue'
const isLogin = ref(true)
const toggleAuth = () => {
isLogin.value = !isLogin.value
}
</script>
<template>
<div class="auth-view">
<div class="auth-box">
<div class="auth-header">
<img src="/icon-192.png" alt="SIBU Logo" class="logo" />
<h1 class="brand-name">SIBU</h1>
<p class="brand-tagline">Moviendo a tu comunidad</p>
</div>
<transition name="fade" mode="out-in">
<LoginForm v-if="isLogin" :on-toggle="toggleAuth" />
<RegisterForm v-else :on-toggle="toggleAuth" :on-success="() => isLogin = true" />
</transition>
</div>
</div>
</template>
<style scoped>
.auth-view {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #42b983 0%, #2c3e50 100%);
padding: 20px;
}
.auth-box {
background: var(--card-bg);
width: 100%;
max-width: 440px;
padding: 40px;
border-radius: 20px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.logo {
width: 80px;
height: 80px;
margin-bottom: 12px;
}
.brand-name {
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
margin: 0;
}
.brand-tagline {
font-size: 14px;
color: var(--text-secondary);
margin: 4px 0 0 0;
}
/* Animations */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useBusStopStore } from '@/stores/busStop'
import { analyticsService } from '@/services/analyticsService'
const { t } = useI18n()
const route = useRoute()
const busStopStore = useBusStopStore()
onMounted(async () => {
const stopId = route.params.id as string
if (stopId) {
await busStopStore.loadBusStopById(stopId)
if (busStopStore.selectedStop) {
analyticsService.logEvent({
event_name: 'stop_selected',
item_id: busStopStore.selectedStop.name,
properties: { stop_id: stopId }
})
}
}
})
</script>
<template>
<div class="bus-stop-details-view">
<div v-if="busStopStore.isLoading">
<p>{{ t('busStop.loadingDetails') }}</p>
</div>
<div v-else-if="busStopStore.error">
<p>{{ t('common.error') }}: {{ busStopStore.error }}</p>
</div>
<div v-else-if="busStopStore.selectedStop">
<h1>{{ busStopStore.selectedStop.name }}</h1>
<p v-if="busStopStore.selectedStop.address">{{ busStopStore.selectedStop.address }}</p>
<p v-if="busStopStore.selectedStop.city">{{ busStopStore.selectedStop.city }}</p>
<div class="amenities">
<h3>{{ t('busStop.amenities') }}</h3>
<ul>
<li v-if="busStopStore.selectedStop.has_shelter">{{ t('busStop.shelter') }}</li>
<li v-if="busStopStore.selectedStop.has_seating">{{ t('busStop.seating') }}</li>
<li v-if="busStopStore.selectedStop.is_accessible">{{ t('busStop.accessible') }}</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
.bus-stop-details-view {
padding: 1rem;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100%;
transition: background-color 0.3s ease, color 0.3s ease;
}
.bus-stop-details-view h1,
.bus-stop-details-view h3 {
color: var(--text-primary);
}
.bus-stop-details-view p {
color: var(--text-secondary);
}
.amenities {
margin-top: 1rem;
padding: 1rem;
background: var(--card-bg);
border-radius: 8px;
border: 1px solid var(--border-color);
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.amenities ul {
list-style: none;
padding: 0;
margin-top: 0.5rem;
}
.amenities li {
padding: 0.5rem 0;
color: var(--text-primary);
}
</style>

View File

@ -0,0 +1,485 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { businessService } from '@/services/businessService'
import { couponsService } from '@/services/couponsService'
import { API_URL } from '@/services/apiClient'
import type { Business, Coupon } from '@/types'
import FavoriteButton from '@/components/FavoriteButton.vue'
const route = useRoute()
const router = useRouter()
const business = ref<Business | null>(null)
const coupons = ref<Coupon[]>([])
const isLoading = ref(true)
import { analyticsService } from '@/services/analyticsService'
onMounted(async () => {
const id = route.params.id as string
try {
const [bizData, allCoupons] = await Promise.all([
businessService.getBusiness(id),
couponsService.getAllCoupons({ active_only: true })
])
business.value = bizData
// Filter coupons for this business
coupons.value = allCoupons.filter(c => c.business_id === id)
analyticsService.logEvent({
event_name: 'screen_view',
screen_name: 'BusinessDetails',
item_id: bizData.name,
properties: { business_id: id }
})
} catch (e) {
console.error('Failed to load business details', e)
} finally {
isLoading.value = false
}
})
function getImageUrl(path: string | null | undefined) {
if (!path) return '/default-business.jpg'
if (path.startsWith('http')) return path
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
}
const goBack = () => router.back()
function handleDirections() {
analyticsService.logEvent({
event_name: 'promo_click',
item_id: 'directions_' + business.value?.name,
properties: {
business_id: business.value?.id,
action: 'get_directions'
}
})
window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(business.value?.address || '')}`, '_blank')
}
</script>
<template>
<div class="business-details-view" v-if="!isLoading && business">
<!-- Hero Section -->
<div class="hero-section">
<img :src="getImageUrl(business.image_url)" alt="Business Image" class="hero-image" />
<div class="hero-overlay"></div>
<button class="back-floating" @click="goBack">
<span class="material-icons">arrow_back</span>
</button>
<div class="fav-floating">
<FavoriteButton
item-type="business"
:item-id="business.id"
:item-name="business.name"
:item-image="business.image_url || undefined"
/>
</div>
<div class="hero-content">
<div class="category-badge premium-font">{{ business.category }}</div>
<h1 class="business-name premium-font">{{ business.name }}</h1>
<div class="area-tag">
<span class="material-icons">location_on</span>
{{ business.area }}
</div>
</div>
</div>
<!-- Details Content -->
<div class="details-container">
<div class="premium-story">
<h2 class="premium-font">Explora Nuestra Historia Para Una Cocina Refinada Y Un Ambiente Atemporal</h2>
<p>"Nuestra historia es una de crecimiento, exploración y recuerdos culinarios inolvidables, donde cada capítulo se sirve con elegancia."</p>
</div>
<!-- Highlights Grid (Inspired by the frame) -->
<div class="highlights-grid">
<div class="highlight-item">
<div class="highlight-header">
<h3 class="premium-font">Herencia Atemporal</h3>
<div class="divider"></div>
</div>
<p>Platos de autor que evolucionan con inspiración y cultura local.</p>
</div>
<div class="highlight-item">
<div class="highlight-header">
<h3 class="premium-font">Platos de Clase Mundial</h3>
<div class="divider"></div>
</div>
<p>Experiencia gastronómica diseñada para deleitar los sentidos más exigentes.</p>
</div>
<div class="highlight-item">
<div class="highlight-header">
<h3 class="premium-font">Emoción y Elegancia</h3>
<div class="divider"></div>
</div>
<p>Veladas realzadas por el encanto atemporal de un ambiente exclusivo.</p>
</div>
<div class="highlight-item">
<div class="highlight-header">
<h3 class="premium-font">Experiencia Inigualable</h3>
<div class="divider"></div>
</div>
<p>Servicio personalizado desde un anfitrión dedicado para tu comodidad.</p>
</div>
</div>
<!-- Info Section -->
<div class="info-sections">
<div class="info-card">
<span class="material-icons">map</span>
<div class="info-text">
<h4>Dirección</h4>
<p>{{ business.address }}</p>
</div>
</div>
<div class="info-card">
<span class="material-icons">phone</span>
<div class="info-text">
<h4>Contacto</h4>
<p>{{ business.phone || 'No disponible' }}</p>
</div>
</div>
<div v-if="business.social_media" class="info-card">
<span class="material-icons">language</span>
<div class="info-text">
<h4>Redes Sociales</h4>
<p>{{ business.social_media }}</p>
</div>
</div>
<div class="info-card">
<span class="material-icons">directions</span>
<div class="info-text">
<h4>Cómo llegar</h4>
<button class="track-directions-btn" @click="handleDirections">
Ver mapa y ruta
</button>
</div>
</div>
</div>
<!-- Offers Section -->
<div v-if="coupons.length > 0" class="offers-section">
<h2 class="section-title premium-font">Ofertas Disponibles</h2>
<div class="coupons-grid">
<div v-for="coupon in coupons" :key="coupon.id" class="coupon-card-detail">
<div class="coupon-header-flex">
<div class="coupon-discount">{{ coupon.discount_percentage }}% OFF</div>
<FavoriteButton
item-type="coupon"
:item-id="coupon.id"
:item-name="coupon.title"
:item-image="coupon.image_url || undefined"
/>
</div>
<div class="coupon-info">
<h3>{{ coupon.title }}</h3>
<p>{{ coupon.description }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="isLoading" class="loading-full">
<div class="loader"></div>
<p>Cargando experiencia premium...</p>
</div>
</template>
<style scoped>
.business-details-view {
min-height: 100vh;
background: var(--bg-primary);
color: var(--text-primary);
padding-bottom: 60px;
}
.premium-font {
font-family: 'Playfair Display', serif;
}
/* Hero Section */
.hero-section {
position: relative;
height: 60vh;
width: 100%;
overflow: hidden;
}
.hero-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.hero-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.8) 100%);
}
.back-floating {
position: absolute;
top: 20px;
left: 20px;
background: rgba(255,255,255,0.2);
backdrop-filter: blur(10px);
border: none;
width: 44px;
height: 44px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
}
.fav-floating {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
}
.coupon-header-flex {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.hero-content {
position: absolute;
bottom: 40px;
left: 40px;
right: 40px;
color: white;
}
.category-badge {
background: var(--active-color);
color: white;
padding: 6px 16px;
border-radius: 100px;
display: inline-block;
font-size: 0.9rem;
font-weight: 700;
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: 1px;
}
.business-name {
font-size: 3.5rem;
margin: 0 0 12px 0;
line-height: 1.1;
}
.area-tag {
display: flex;
align-items: center;
gap: 8px;
font-size: 1.2rem;
opacity: 0.9;
}
/* Content */
.details-container {
max-width: 1000px;
margin: -40px auto 0;
position: relative;
background: var(--bg-primary);
border-radius: 30px 30px 0 0;
padding: 60px 40px;
box-shadow: 0 -20px 40px rgba(0,0,0,0.1);
}
.premium-story {
text-align: center;
max-width: 700px;
margin: 0 auto 60px;
}
.premium-story h2 {
font-size: 2.2rem;
margin-bottom: 24px;
color: var(--text-primary);
}
.premium-story p {
font-size: 1.2rem;
font-style: italic;
color: var(--text-secondary);
line-height: 1.6;
}
/* Highlights Grid */
.highlights-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 40px;
margin-bottom: 80px;
}
.highlight-item {
padding: 20px;
}
.highlight-header {
margin-bottom: 16px;
}
.highlight-header h3 {
font-size: 1.4rem;
margin-bottom: 8px;
}
.divider {
width: 100%;
height: 1px;
background: var(--border-color);
}
.highlight-item p {
color: var(--text-secondary);
line-height: 1.5;
}
/* Info Cards */
.info-sections {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 80px;
}
.info-card {
background: var(--bg-secondary);
padding: 24px;
border-radius: 20px;
display: flex;
align-items: flex-start;
gap: 16px;
}
.info-card .material-icons {
color: var(--active-color);
font-size: 2rem;
}
.info-text h4 {
margin: 0 0 4px 0;
font-size: 0.9rem;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 1px;
}
.info-text p {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.track-directions-btn {
background: var(--active-color);
color: white;
border: none;
padding: 8px 16px;
border-radius: 8px;
font-weight: 700;
cursor: pointer;
margin-top: 4px;
}
/* Offers Section */
.offers-section {
border-top: 1px solid var(--border-color);
padding-top: 60px;
}
.section-title {
font-size: 2rem;
margin-bottom: 40px;
}
.coupons-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
}
.coupon-card-detail {
background: var(--card-bg);
border: 2px dashed var(--active-color);
padding: 24px;
border-radius: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.coupon-discount {
font-size: 1.5rem;
font-weight: 900;
color: var(--active-color);
}
.coupon-info h3 {
margin: 0 0 8px 0;
font-size: 1.2rem;
}
.coupon-info p {
margin: 0;
color: var(--text-secondary);
font-size: 0.95rem;
}
/* Loader */
.loading-full {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
}
.loader {
width: 50px;
height: 50px;
border: 3px solid var(--bg-secondary);
border-top-color: var(--active-color);
border-radius: 50%;
animation: spin 1s infinite linear;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.hero-section { height: 50vh; }
.business-name { font-size: 2.5rem; }
.highlights-grid { grid-template-columns: 1fr; }
.details-container { padding: 40px 20px; }
.hero-content { left: 20px; bottom: 30px; }
}
</style>

View File

@ -0,0 +1,646 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCouponStore } from '@/stores/coupon'
import { API_URL } from '@/services/apiClient'
import type { Coupon } from '@/types'
import FavoriteButton from '@/components/FavoriteButton.vue'
const { t } = useI18n()
const couponStore = useCouponStore()
const showRedeemModal = ref(false)
const selectedCoupon = ref<Coupon | null>(null)
const searchQuery = ref('')
const selectedCategory = ref('Todas')
const showFilterSheet = ref(false)
const categories = ['Todas', 'Restaurante', 'Turismo', 'Bebidas', 'Comercio']
onMounted(() => {
couponStore.loadCoupons()
})
const filteredCoupons = computed(() => {
return couponStore.coupons.filter(c => {
const matchesSearch = c.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
c.business_name?.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesCategory = selectedCategory.value === 'Todas' || c.category === selectedCategory.value
return matchesSearch && matchesCategory
})
})
function getImageUrl(path: string | null | undefined) {
if (!path) return '/default-coupon.png'
if (path.startsWith('http')) return path
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
}
import { analyticsService } from '@/services/analyticsService'
function openCoupon(coupon: Coupon) {
selectedCoupon.value = coupon
showRedeemModal.value = true
analyticsService.logEvent({
event_name: 'promo_view',
item_id: coupon.title,
properties: { coupon_id: coupon.id, business: coupon.business_name }
})
}
function handleDirections() {
if (!selectedCoupon.value) return
analyticsService.logEvent({
event_name: 'promo_click',
item_id: 'directions_' + selectedCoupon.value.business_name,
properties: {
coupon_id: selectedCoupon.value.id,
action: 'get_directions'
}
})
window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(selectedCoupon.value.business_address || selectedCoupon.value.business_name || '')}`, '_blank')
}
function getCategoryIcon(category?: string | null) {
const cat = category?.toLowerCase() || ''
if (cat.includes('restaurante')) return 'restaurant'
if (cat.includes('turismo')) return 'landscape'
if (cat.includes('bebida')) return 'local_bar'
if (cat.includes('comercio')) return 'store'
return 'local_offer'
}
</script>
<template>
<div class="coupons-view">
<header class="mobile-header">
<button class="icon-btn-back" @click="$router.back()">
<span class="material-icons">arrow_back</span>
</button>
<div class="header-center">
<span class="material-icons brand-icon">local_offer</span>
<h1>{{ t('coupons.title') }}</h1>
</div>
<div class="header-right"></div>
</header>
<div class="search-section">
<div class="search-bar-rounded">
<span class="material-icons search-icon">search</span>
<input
v-model="searchQuery"
type="text"
:placeholder="t('coupons.searchPlaceholder')"
class="search-input"
>
</div>
<button class="filter-btn-square" @click="showFilterSheet = true">
<span class="material-icons">tune</span>
</button>
</div>
<div class="offers-stats">
<span class="stat-dot"></span>
<span class="stat-label">{{ t('coupons.offersCount', { count: filteredCoupons.length }) }}</span>
</div>
<div v-if="couponStore.isLoading" class="loading-container">
<span class="material-icons spin">refresh</span>
<p>{{ t('coupons.loadingCoupons') }}</p>
</div>
<div v-else-if="couponStore.error" class="error-container">
<span class="material-icons">error_outline</span>
<p>{{ t('common.error') }}: {{ couponStore.error }}</p>
</div>
<div v-else-if="filteredCoupons.length === 0" class="empty-container">
<span class="material-icons">search_off</span>
<p>No se encontraron resultados para tu búsqueda.</p>
</div>
<div v-else class="coupons-grid-new">
<div
v-for="coupon in filteredCoupons"
:key="coupon.id"
class="offer-card-new"
@click="openCoupon(coupon)"
>
<div class="offer-image-wrapper">
<img :src="getImageUrl(coupon.image_url)" :alt="coupon.title" class="offer-img">
<div class="status-badge" :class="{ 'tmr': coupon.title.toLowerCase().includes('mañana') || (coupon.description?.toLowerCase().includes('mañana') ?? false) }">
<span class="material-icons">schedule</span>
{{ coupon.title.toLowerCase().includes('mañana') ? t('coupons.tomorrow') : t('coupons.active') }}
</div>
<div class="favorite-button-wrapper">
<FavoriteButton
item-type="coupon"
:item-id="coupon.id"
:item-name="coupon.title"
:item-image="coupon.image_url || undefined"
/>
</div>
</div>
<div class="offer-content">
<h3 class="offer-title">{{ coupon.business_name || 'Restaurante' }}</h3>
<p class="offer-benefit">{{ coupon.title }}</p>
</div>
</div>
</div>
<!-- Category Filter Sheet -->
<div v-if="showFilterSheet" class="bottom-sheet-overlay" @click.self="showFilterSheet = false">
<div class="bottom-sheet">
<div class="sheet-handle"></div>
<div class="sheet-header">
<h3>{{ t('coupons.filterByCategory') }}</h3>
</div>
<div class="sheet-body">
<div v-for="cat in categories" :key="cat" class="filter-option" @click="selectedCategory = cat; showFilterSheet = false">
<span class="material-icons">{{ getCategoryIcon(cat) }}</span>
<span>{{ cat }}</span>
<span v-if="selectedCategory === cat" class="material-icons check">check_circle</span>
</div>
</div>
<div class="sheet-footer">
<button class="apply-btn-full" @click="showFilterSheet = false">
{{ t('coupons.apply') }}
</button>
</div>
</div>
</div>
<!-- Detail Modal -->
<div v-if="showRedeemModal" class="modal-overlay-new" @click.self="showRedeemModal = false">
<div class="detail-modal-new" v-if="selectedCoupon">
<div class="modal-header-new">
<h3>{{ t('coupons.offerDetails') }}</h3>
<button class="close-btn-round" @click="showRedeemModal = false">
<span class="material-icons">close</span>
</button>
</div>
<div class="modal-scroll-body">
<div class="modal-hero-image">
<img :src="getImageUrl(selectedCoupon.image_url)" alt="Header Image">
</div>
<div class="modal-info-section">
<h2 class="modal-business-title">{{ selectedCoupon.business_name }}</h2>
<div class="benefit-highlight-box">
<span class="material-icons icon-label">local_offer</span>
<span>{{ selectedCoupon.title }}</span>
</div>
<div class="info-block">
<div class="block-header">
<span class="material-icons icon-desc">description</span>
<h4>{{ t('coupons.description') }}</h4>
</div>
<p class="block-text">{{ selectedCoupon.description || 'Sin descripción adicional.' }}</p>
</div>
<div class="info-block">
<div class="block-header">
<span class="material-icons icon-date">calendar_today</span>
<h4>{{ t('coupons.validity') }}</h4>
</div>
<div class="validity-badge">
{{ t('coupons.validUntil') }} {{ selectedCoupon.valid_until || '31/1/2026' }}
</div>
</div>
<div v-if="selectedCoupon.category" class="info-block">
<div class="block-header">
<span class="material-icons icon-cat">category</span>
<h4>{{ t('coupons.category') }}</h4>
</div>
<div class="category-badge-simple">
{{ selectedCoupon.category.toLowerCase() }}
</div>
</div>
</div>
</div>
<div class="modal-footer-new">
<button class="location-btn-green" @click="handleDirections">
<span class="material-icons">place</span>
{{ t('coupons.viewLocation') }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.coupons-view {
padding: 0;
background: var(--bg-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Mobile Header */
.mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--bg-primary);
position: sticky;
top: 0;
z-index: 100;
}
.header-center {
display: flex;
align-items: center;
gap: 8px;
}
.header-center h1 {
font-size: 1.1rem;
font-weight: 700;
margin: 0;
}
.brand-icon {
color: #fee715;
font-size: 24px;
}
.icon-btn-back {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
}
.header-right { width: 40px; }
/* Search and Filters */
.search-section {
padding: 0 16px;
display: flex;
gap: 12px;
margin-top: 10px;
}
.search-bar-rounded {
flex: 1;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 10px;
}
.search-bar-rounded:focus-within {
border-color: #fee715;
}
.search-icon { color: var(--text-secondary); }
.search-input {
border: none;
background: transparent;
width: 100%;
color: var(--text-primary);
font-size: 0.95rem;
outline: none;
}
.filter-btn-square {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
cursor: pointer;
}
/* Stats */
.offers-stats {
padding: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.stat-dot {
width: 8px;
height: 8px;
background: #fee715;
border-radius: 50%;
}
.stat-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
}
/* Grid and Cards */
.coupons-grid-new {
padding: 0 16px 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.offer-card-new {
background: var(--card-bg);
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px var(--shadow);
}
.offer-image-wrapper {
position: relative;
aspect-ratio: 1/1;
background: var(--bg-secondary);
}
.offer-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.status-badge {
position: absolute;
top: 8px;
right: 8px;
background: #166534;
color: white;
padding: 4px 8px;
border-radius: 20px;
font-size: 0.65rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 4px;
}
.status-badge.tmr { background: #f97316; }
.status-badge .material-icons { font-size: 10px; }
.favorite-button-wrapper {
position: absolute;
top: 8px;
left: 8px;
z-index: 5;
}
.offer-content {
padding: 10px;
}
.offer-title {
font-size: 0.85rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.offer-benefit {
font-size: 0.75rem;
color: var(--text-secondary);
line-height: 1.2;
}
/* Bottom Sheet */
.bottom-sheet-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 2000;
display: flex;
align-items: flex-end;
}
.bottom-sheet {
background: var(--card-bg);
width: 100%;
border-radius: 24px 24px 0 0;
padding: 12px 20px 30px;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.sheet-handle {
width: 40px;
height: 4px;
background: var(--border-color);
border-radius: 2px;
margin: 0 auto 16px;
}
.sheet-header h3 {
font-size: 1.1rem;
font-weight: 800;
margin-bottom: 20px;
}
.filter-option {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
}
.filter-option .material-icons { color: var(--text-secondary); }
.filter-option span { font-weight: 600; color: var(--text-primary); }
.filter-option .check { color: #fee715; margin-left: auto; }
.sheet-footer { margin-top: 24px; }
.apply-btn-full {
width: 100%;
padding: 14px;
background: #fee715;
color: #101820;
border: none;
border-radius: 12px;
font-weight: 800;
cursor: pointer;
}
/* Detail Modal New */
.modal-overlay-new {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 3000;
padding: 20px;
}
.detail-modal-new {
background: var(--card-bg);
width: 100%;
max-width: 480px;
height: 85vh;
border-radius: 28px;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.modal-header-new {
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.modal-header-new h3 { font-size: 1rem; font-weight: 800; }
.close-btn-round {
background: var(--bg-secondary);
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
cursor: pointer;
}
.modal-scroll-body {
flex: 1;
overflow-y: auto;
}
.modal-hero-image img {
width: 100%;
height: 220px;
object-fit: cover;
}
.modal-info-section {
padding: 20px;
}
.modal-business-title {
font-size: 1.3rem;
font-weight: 800;
margin-bottom: 16px;
}
.benefit-highlight-box {
background: #fefce8; /* Light yellow in light mode */
border: 1px solid #fee715;
padding: 12px 16px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
color: #854d0e;
margin-bottom: 24px;
}
.dark .benefit-highlight-box {
background: rgba(254, 231, 21, 0.1);
color: #fee715;
}
.icon-label { color: #facc15; }
.info-block {
margin-bottom: 20px;
}
.block-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.block-header h4 { font-size: 0.9rem; font-weight: 700; color: var(--text-primary); }
.block-header .material-icons { font-size: 18px; color: var(--text-secondary); }
.block-text { font-size: 0.9rem; color: var(--text-secondary); line-height: 1.4; }
.validity-badge {
display: inline-block;
background: #dcfce7;
color: #166534;
padding: 6px 12px;
border-radius: 8px;
font-weight: 700;
font-size: 0.85rem;
}
.dark .validity-badge { background: rgba(22, 101, 52, 0.2); color: #4ade80; }
.category-badge-simple {
display: inline-block;
background: #fefce8;
color: #854d0e;
padding: 6px 12px;
border-radius: 8px;
font-weight: 700;
font-size: 0.85rem;
}
.modal-footer-new {
padding: 20px;
background: var(--card-bg);
}
.location-btn-green {
width: 100%;
padding: 16px;
background: #10b981;
color: white;
border: none;
border-radius: 16px;
font-weight: 800;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.loading-container, .error-container, .empty-container {
padding: 4rem 2rem;
text-align: center;
color: var(--text-secondary);
}
.spin { animation: spin 1s linear infinite; }
@keyframes spin { 100% { transform: rotate(360deg); } }
</style>

View File

@ -0,0 +1,588 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { businessService } from '@/services/businessService';
import { API_URL } from '@/services/apiClient';
import type { Business } from '@/types';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import FavoriteButton from '@/components/FavoriteButton.vue';
const { t } = useI18n();
const router = useRouter();
const businesses = ref<Business[]>([]);
const isLoading = ref(true);
const selectedArea = ref('Todas');
const selectedCategory = ref('Todas');
import { analyticsService } from '@/services/analyticsService';
onMounted(async () => {
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'Discover' });
try {
businesses.value = await businessService.getAllBusinesses();
} catch (error) {
console.error('Error loading tourist spots:', error);
} finally {
isLoading.value = false;
}
});
function handleExplore(biz: Business) {
analyticsService.logEvent({
event_name: 'promo_click',
item_id: biz.name,
properties: { business_id: biz.id }
});
router.push('/business/' + biz.id);
}
const filteredBusinesses = computed(() => {
let filtered = businesses.value;
if (selectedArea.value !== 'Todas') {
filtered = filtered.filter(b => b.area === selectedArea.value);
}
if (selectedCategory.value !== 'Todas') {
filtered = filtered.filter(b => b.category === selectedCategory.value);
}
return filtered;
});
const categories = computed<string[]>(() => {
const cats = new Set(businesses.value.map(b => b.category).filter(Boolean) as string[]);
return ['Todas', ...Array.from(cats)];
});
function getImageUrl(path: string | null | undefined) {
if (!path) return '/default-business.jpg';
if (path.startsWith('http')) return path;
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`;
}
function getCategoryIcon(category: string) {
const icons: Record<string, string> = {
'Restaurante': 'restaurant',
'Turismo': 'landscape',
'Bebidas': 'local_bar',
'Comercio': 'store',
'Hotel': 'hotel',
'Café': 'local_cafe'
};
return icons[category] || 'place';
}
</script>
<template>
<div class="discover-view">
<!-- Compact Glass Header -->
<header class="premium-header">
<div class="header-glass-card">
<div class="header-icon-box">
<span class="material-icons">explore</span>
<div class="icon-pulse"></div>
</div>
<div class="header-text-box">
<h1 class="gradient-text">{{ t('discover.title') }}</h1>
<p class="subtitle">{{ t('discover.subtitle') }}</p>
</div>
</div>
</header>
<!-- Integrated High-Tech Filters -->
<div class="filters-panel">
<div class="glass-filters">
<div class="filter-item">
<div class="filter-label">
<span class="material-icons">location_on</span>
<span>Región</span>
</div>
<div class="custom-select-box">
<select v-model="selectedArea" class="modern-select">
<option value="Todas">{{ t('discover.allAreas') }}</option>
<option value="Boquete">Boquete</option>
<option value="Dolega">Dolega</option>
<option value="David">David</option>
</select>
<span class="material-icons">expand_more</span>
</div>
</div>
<div class="filter-divider"></div>
<div class="filter-item">
<div class="filter-label">
<span class="material-icons">category</span>
<span>Categoría</span>
</div>
<div class="custom-select-box">
<select v-model="selectedCategory" class="modern-select">
<option v-for="cat in categories" :key="cat" :value="cat">
{{ cat }}
</option>
</select>
<span class="material-icons">expand_more</span>
</div>
</div>
</div>
</div>
<!-- Main Content Area -->
<main class="discover-main">
<!-- Loading Experience -->
<div v-if="isLoading" class="loading-container">
<div class="nexus-loader">
<div class="nexus-dot"></div>
<div class="nexus-ring"></div>
</div>
<p class="loading-text">Sincronizando con SIBU...</p>
</div>
<!-- Empty State -->
<div v-else-if="filteredBusinesses.length === 0" class="empty-nexus">
<div class="empty-nexus-box">
<span class="material-icons">search_off</span>
<h3>Sin resultados</h3>
<p>La búsqueda no devolvió datos en esta frecuencia.</p>
<button class="reboot-btn" @click="selectedArea = 'Todas'; selectedCategory = 'Todas'">
REINICIAR SENSORES
</button>
</div>
</div>
<!-- Business Grid Premium -->
<TransitionGroup
v-else
name="stagger-list"
tag="div"
class="premium-business-grid"
>
<div v-for="(biz, index) in filteredBusinesses"
:key="biz.id"
class="nexus-card"
@click="handleExplore(biz)"
:style="{ '--order': index }"
>
<div class="nexus-card-inner">
<div class="nexus-image-container">
<img :src="getImageUrl(biz.image_url)" alt="" class="nexus-img">
<div class="nexus-overlay-gradient"></div>
<!-- Floating Badges -->
<div class="nexus-badge category">
<span class="material-icons">{{ getCategoryIcon(biz.category || '') }}</span>
</div>
<div class="nexus-fav">
<FavoriteButton
item-type="business"
:item-id="biz.id"
:item-name="biz.name"
:item-image="biz.image_url || undefined"
/>
</div>
</div>
<div class="nexus-card-details">
<h3 class="nexus-biz-name">{{ biz.name }}</h3>
<div class="nexus-biz-meta">
<span class="area"><span class="material-icons">near_me</span>{{ biz.area }}</span>
</div>
</div>
</div>
</div>
</TransitionGroup>
</main>
</div>
</template>
<style scoped>
.discover-view {
min-height: 100vh;
position: relative;
padding: 20px 16px 150px;
overflow-x: hidden;
}
/* Contenido directo */
.premium-header,
.filters-panel,
.discover-main {
position: relative;
z-index: 1;
}
/* Tarjetas nitidas */
.header-glass-card,
.glass-filters,
.nexus-card-inner {
background: var(--card-bg) !important;
backdrop-filter: none !important;
border: 1px solid var(--border-color) !important;
}
/* Premium Header */
.premium-header {
margin-bottom: 24px;
}
.header-glass-card {
background: var(--card-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 24px;
padding: 20px;
display: flex;
align-items: center;
gap: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.header-icon-box {
width: 56px;
height: 56px;
background: var(--active-bg);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.header-icon-box .material-icons {
color: var(--active-color);
font-size: 28px;
z-index: 1;
}
.icon-pulse {
position: absolute;
width: 100%;
height: 100%;
border-radius: 18px;
border: 2px solid var(--active-color);
animation: pulse-out 2s infinite;
}
@keyframes pulse-out {
0% { transform: scale(1); opacity: 0.5; }
100% { transform: scale(1.5); opacity: 0; }
}
.header-text-box h1 {
font-size: 1.5rem;
font-weight: 900;
margin: 0;
letter-spacing: -0.02em;
}
.subtitle {
margin: 4px 0 0;
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 500;
}
/* Unified Filters */
.filters-panel {
margin-bottom: 24px;
}
.glass-filters {
background: var(--glass-bg);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 20px;
display: flex;
padding: 8px;
gap: 8px;
}
.filter-item {
flex: 1;
display: flex;
flex-direction: column;
padding: 8px 12px;
}
.filter-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-secondary);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.filter-label .material-icons {
font-size: 14px;
color: var(--active-color);
}
.custom-select-box {
position: relative;
display: flex;
align-items: center;
}
.modern-select {
width: 100%;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 0.95rem;
font-weight: 700;
padding-right: 24px;
cursor: pointer;
outline: none;
appearance: none;
}
.custom-select-box .material-icons {
position: absolute;
right: 0;
pointer-events: none;
color: var(--text-secondary);
font-size: 18px;
}
.filter-divider {
width: 1px;
background: var(--border-color);
margin: 10px 0;
}
/* Premium Grid */
.premium-business-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
@media (max-width: 1024px) {
.premium-business-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.premium-business-grid {
grid-template-columns: repeat(2, 1fr); /* Mantenemos 2 en móvil para aprovechar el espacio nexus */
gap: 10px;
}
}
.nexus-card {
perspective: 1000px;
}
.nexus-card-inner {
background: var(--card-bg);
border-radius: 24px;
overflow: hidden;
border: 1px solid var(--border-color);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
height: 100%;
display: flex;
flex-direction: column;
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
.nexus-card:hover .nexus-card-inner {
transform: translateY(-8px);
border-color: var(--active-color);
box-shadow: 0 15px 30px rgba(0,0,0,0.3);
}
.nexus-image-container {
position: relative;
aspect-ratio: 1;
overflow: hidden;
}
.nexus-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.nexus-card:hover .nexus-img {
transform: scale(1.1);
}
.nexus-overlay-gradient {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(15, 23, 42, 0.8) 0%, transparent 60%);
}
.nexus-badge {
position: absolute;
top: 12px;
left: 12px;
width: 36px;
height: 36px;
background: var(--active-bg);
backdrop-filter: blur(8px);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--active-color);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.nexus-fav {
position: absolute;
top: 12px;
right: 12px;
}
.nexus-card-details {
padding: 16px;
background: var(--card-bg);
}
.nexus-biz-name {
margin: 0;
font-size: 1rem;
font-weight: 800;
color: var(--text-primary);
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-clamp: 2;
overflow: hidden;
}
.nexus-biz-meta {
margin-top: 8px;
display: flex;
align-items: center;
gap: 12px;
}
.area {
font-size: 0.75rem;
color: var(--text-secondary);
font-weight: 700;
display: flex;
align-items: center;
gap: 4px;
}
.area .material-icons {
font-size: 14px;
color: var(--active-color);
}
/* Loading & Empty States */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100px 0;
}
.nexus-loader {
position: relative;
width: 60px;
height: 60px;
}
.nexus-dot {
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 12px;
background: var(--active-color);
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 20px var(--active-color);
}
.nexus-ring {
width: 60px;
height: 60px;
border: 4px solid var(--active-bg);
border-top-color: var(--active-color);
border-radius: 50%;
animation: spin 1s infinite linear;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 24px;
font-weight: 700;
color: var(--text-secondary);
letter-spacing: 1px;
}
.empty-nexus {
padding: 60px 0;
text-align: center;
}
.empty-nexus-box {
background: var(--card-bg);
border-radius: 30px;
padding: 40px;
border: 1px dashed var(--border-color);
}
.empty-nexus-box .material-icons {
font-size: 48px;
color: var(--text-secondary);
margin-bottom: 20px;
}
.reboot-btn {
margin-top: 24px;
background: var(--active-color);
color: #101820;
border: none;
padding: 12px 24px;
border-radius: 12px;
font-weight: 800;
cursor: pointer;
}
/* Animations */
.stagger-list-enter-active {
transition: all 0.5s ease;
transition-delay: calc(0.1s * var(--order));
}
.stagger-list-enter-from {
opacity: 0;
transform: translateY(30px);
}
@media (max-width: 480px) {
.header-glass-card {
padding: 16px;
gap: 16px;
}
.header-icon-box {
width: 44px;
height: 44px;
}
.header-text-box h1 {
font-size: 1.2rem;
}
}
</style>

View File

@ -0,0 +1,403 @@
<template>
<div class="driver-dashboard">
<div class="dashboard-header">
<div class="user-welcome">
<h1>Hola, {{ userName }}</h1>
<p>Panel de Control de Transportista</p>
</div>
<button class="logout-btn" @click="handleLogout">Cerrar Sesión</button>
</div>
<!-- Verification Status Banner -->
<div v-if="!isVerified" class="status-banner pending">
<span class="material-icons">hourglass_empty</span>
<div class="banner-text">
<h3>Tu cuenta está pendiente de verificación</h3>
<p>Un administrador revisará tus documentos pronto. Mientras tanto, algunas funciones están limitadas.</p>
</div>
</div>
<div v-else class="status-banner verified">
<span class="material-icons">verified</span>
<div class="banner-text">
<h3>Cuenta Verificada</h3>
<p>¡Felicidades! Tu cuenta está activa y puedes operar en el sistema.</p>
</div>
</div>
<div class="dashboard-grid">
<!-- Profile Status Card -->
<div class="dashboard-card">
<h3>Mi Perfil</h3>
<div class="profile-preview">
<div class="info-row">
<label>Cédula:</label>
<span>{{ driverProfile?.cedula || '---' }}</span>
</div>
<div class="info-row">
<label>Vehículo:</label>
<span>{{ (driverProfile?.vehicle_type || '---').toUpperCase() }}</span>
</div>
<div class="info-row">
<label>Placa:</label>
<span>{{ driverProfile?.license_plate || '---' }}</span>
</div>
</div>
<button class="secondary-btn" @click="editProfile" :disabled="!isVerified">Editar Perfil</button>
</div>
<!-- Real-time Availability Card -->
<div class="dashboard-card" :class="{ 'active-service': isInService }">
<div class="card-header-flex">
<h3>Estado en Tiempo Real</h3>
<div class="status-indicator" :class="{ 'online': isInService }"></div>
</div>
<div class="service-controls">
<p v-if="!isInService" class="service-hint">Activa tu ubicación para que los pasajeros puedan verte en el mapa.</p>
<p v-else class="service-hint active">Tu ubicación se está transmitiendo en tiempo real.</p>
<button
@click="toggleService"
:class="isInService ? 'service-btn stop' : 'service-btn start'"
:disabled="!isVerified || isLocating"
>
<span class="material-icons">{{ isInService ? 'location_off' : 'location_on' }}</span>
{{ isInService ? 'Detener Servicio' : 'Iniciar Servicio' }}
</button>
<div v-if="isLocating" class="locating-spinner">
<span class="material-icons spin">refresh</span>
Obteniendo ubicación...
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { authService } from '@/services/authService'
import { telemetryService } from '@/services/telemetryService'
const router = useRouter()
const userName = localStorage.getItem('user_name') || 'Conductor'
const isVerified = ref(false)
const driverProfile = ref<any>(null)
// Service tracking state
const isInService = ref(false)
const isLocating = ref(false)
const watchId = ref<number | null>(null)
const lastUpdate = ref<number>(0)
const minUpdateInterval = 10000 // 10 seconds
onMounted(async () => {
await fetchStatus()
// Restore state if was in service (optional, simpler for now to start off)
const savedService = localStorage.getItem('driver_in_service')
if (savedService === 'true' && isVerified.value) {
// startService() // Consider if auto-start is safe
}
})
onUnmounted(() => {
stopService()
})
async function fetchStatus() {
try {
const user = await authService.getCurrentUser()
isVerified.value = user.is_verified
driverProfile.value = user.driver_profile
// Update local storage just in case
localStorage.setItem('user_verified', user.is_verified.toString())
} catch (e) {
console.error('Failed to fetch driver status', e)
}
}
function handleLogout() {
localStorage.clear()
router.push('/login')
}
async function toggleService() {
if (isInService.value) {
stopService()
} else {
await startService()
}
}
async function startService() {
if (!navigator.geolocation) {
alert('Tu navegador no soporta geolocalización')
return
}
isLocating.value = true
isInService.value = true
localStorage.setItem('driver_in_service', 'true')
watchId.value = navigator.geolocation.watchPosition(
async (position) => {
isLocating.value = false
const now = Date.now()
// Throttling updates to save battery/bandwidth
if (now - lastUpdate.value >= minUpdateInterval) {
try {
await telemetryService.sendTelemetry({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
speed: position.coords.speed || undefined,
heading: position.coords.heading || undefined,
status: 'active'
})
lastUpdate.value = now
} catch (e) {
console.error('Failed to send telemetry', e)
}
}
},
(error) => {
console.error('Geolocation error', error)
isLocating.value = false
if (error.code === error.PERMISSION_DENIED) {
alert('Debes permitir el acceso a la ubicación para usar esta función')
stopService()
}
},
{
enableHighAccuracy: true,
maximumAge: 30000,
timeout: 27000
}
)
}
function stopService() {
if (watchId.value !== null) {
navigator.geolocation.clearWatch(watchId.value)
watchId.value = null
}
isInService.value = false
isLocating.value = false
localStorage.setItem('driver_in_service', 'false')
// Optionally notify backend that we are offline
telemetryService.sendTelemetry({
latitude: 0,
longitude: 0,
status: 'offline'
}).catch(() => {})
}
function editProfile() {
alert('Función de edición de perfil próximamente')
}
</script>
<style scoped>
.driver-dashboard {
padding: 24px;
max-width: 800px;
margin: 0 auto;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
}
.user-welcome h1 { margin: 0; font-size: 28px; }
.user-welcome p { color: #666; margin: 4px 0 0 0; }
.logout-btn {
background: transparent;
border: 1px solid #ddd;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
}
.status-banner {
display: flex;
align-items: center;
gap: 16px;
padding: 24px;
border-radius: 12px;
margin-bottom: 24px;
}
.status-banner.pending {
background-color: #fff8e1;
color: #856404;
border: 1px solid #ffeeba;
}
.status-banner.verified {
background-color: #e8f5e9;
color: #2e7d32;
border: 1px solid #c8e6c9;
}
.banner-text h3 { margin: 0; font-size: 18px; }
.banner-text p { margin: 4px 0 0 0; font-size: 14px; opacity: 0.9; }
.status-banner .material-icons { font-size: 48px; }
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.dashboard-card {
background: var(--card-bg);
padding: 24px;
border-radius: 16px;
box-shadow: 0 4px 12px var(--shadow);
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
transition: all 0.3s ease;
}
.dashboard-card.active-service {
border-color: var(--active-color);
box-shadow: 0 0 15px rgba(25, 118, 210, 0.2);
}
.dashboard-card h3 { margin: 0 0 20px 0; font-size: 1.25rem; color: var(--text-primary); }
.card-header-flex {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card-header-flex h3 { margin: 0; }
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ccc;
}
.status-indicator.online {
background: #4caf50;
box-shadow: 0 0 10px #4caf50;
animation: blink 2s infinite;
}
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
font-size: 14px;
}
.info-row label { color: var(--text-secondary); }
.info-row span { font-weight: 600; color: var(--text-primary); }
.secondary-btn {
margin-top: auto;
padding: 12px;
background: var(--bg-secondary);
color: var(--text-primary);
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.secondary-btn:hover:not(:disabled) { background: var(--hover-bg); }
.secondary-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.service-controls {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
justify-content: center;
}
.service-hint {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
}
.service-hint.active {
color: var(--active-color);
font-weight: 600;
}
.service-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
border-radius: 12px;
border: none;
font-weight: 800;
font-size: 16px;
cursor: pointer;
transition: all 0.2s;
}
.service-btn.start {
background: var(--text-primary);
color: var(--bg-primary);
}
.service-btn.stop {
background: #ef5350;
color: white;
}
.locating-spinner {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.spin {
animation: spin 1s linear infinite;
font-size: 16px;
}
@keyframes spin { 100% { transform: rotate(360deg); } }
.unavailable { opacity: 0.7; }
.coming-soon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
color: var(--text-secondary);
}
.coming-soon .material-icons { font-size: 48px; margin-bottom: 8px; }
</style>

View File

@ -0,0 +1,596 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useFavoritesStore } from '@/stores/favorites'
import { API_URL } from '@/services/apiClient'
const { t } = useI18n()
const router = useRouter()
const favoritesStore = useFavoritesStore()
const selectedFilter = ref('all')
onMounted(async () => {
await favoritesStore.loadFavorites()
})
function getImageUrl(path?: string) {
if (!path) return `https://ui-avatars.com/api/?name=Favorite&background=fee715&color=101820`
if (path.startsWith('http')) return path
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
}
async function removeFavorite(event: Event, itemType: string, itemId: string) {
event.stopPropagation()
if (confirm(t('favorites.removeConfirm'))) {
await favoritesStore.removeFavorite(itemType, itemId)
}
}
const navigateToItem = (item: any) => {
if (item.item_type === 'route') {
router.push('/schedules')
} else if (item.item_type === 'taxi') {
router.push('/taxi')
} else if (item.item_type === 'business') {
router.push('/business/' + item.item_id)
}
}
</script>
<template>
<div class="favorites-view">
<!-- Hero Header -->
<header class="favorites-header">
<div class="header-overlay"></div>
<div class="header-content">
<div class="header-icon">
<span class="material-icons">stars</span>
</div>
<h1>{{ t('favorites.title') }}</h1>
<p class="subtitle">{{ t('favorites.subtitle') }}</p>
</div>
</header>
<!-- Category Filter (igual a Descubrir) -->
<div class="filters-container-wrapper">
<div class="filters-card">
<div class="filter-group">
<label>
<span class="material-icons">filter_list</span>
Filtrar favoritos
</label>
<div class="select-wrapper">
<select v-model="selectedFilter" class="filter-select">
<option value="all">Todas las categorías</option>
<option v-if="favoritesStore.routes.length > 0" value="routes">Rutas ({{ favoritesStore.routes.length }})</option>
<option v-if="favoritesStore.taxis.length > 0" value="taxis">Taxis ({{ favoritesStore.taxis.length }})</option>
<option v-if="favoritesStore.businesses.length > 0" value="businesses">Negocios ({{ favoritesStore.businesses.length }})</option>
<option v-if="favoritesStore.coupons.length > 0" value="coupons">Eventos ({{ favoritesStore.coupons.length }})</option>
</select>
<span class="material-icons dropdown-icon">expand_more</span>
</div>
</div>
</div>
</div>
<div v-if="favoritesStore.isLoading" class="loading">
<div class="spinner"></div>
<p>{{ t('common.loading') }}</p>
</div>
<div v-else class="dashboard-area">
<!-- Global Empty State -->
<div v-if="favoritesStore.favorites.length === 0" class="empty-state">
<div class="empty-icon">
<span class="material-icons">favorite_border</span>
</div>
<h3>{{ t('favorites.empty.subtitle') }}</h3>
</div>
<div v-else class="sections-list">
<!-- ROUTES SECTION -->
<section v-if="(selectedFilter === 'all' || selectedFilter === 'routes') && favoritesStore.routes.length > 0" class="fav-section">
<div class="section-header">
<span class="material-icons">directions_bus</span>
<h2>{{ t('favorites.tabs.routes') }}</h2>
</div>
<div class="compact-list">
<div v-for="item in favoritesStore.routes" :key="item.id" class="list-item" @click="navigateToItem(item)">
<div class="item-visual bg-yellow">
<span class="material-icons">directions_bus</span>
</div>
<div class="item-details">
<h3>{{ item.item_name }}</h3>
<p>{{ t('favorites.viewSchedules') }}</p>
</div>
<button class="item-remove" @click.stop="removeFavorite($event, item.item_type, item.item_id)">
<span class="material-icons">close</span>
</button>
</div>
</div>
</section>
<!-- TAXIS SECTION -->
<section v-if="(selectedFilter === 'all' || selectedFilter === 'taxis') && favoritesStore.taxis.length > 0" class="fav-section">
<div class="section-header">
<span class="material-icons">local_taxi</span>
<h2>{{ t('favorites.tabs.taxis') }}</h2>
</div>
<div class="compact-list">
<div v-for="item in favoritesStore.taxis" :key="item.id" class="list-item" @click="navigateToItem(item)">
<div class="item-visual thumb">
<img :src="getImageUrl(item.item_image)" :alt="item.item_name">
</div>
<div class="item-details">
<h3>{{ item.item_name }}</h3>
<p>{{ t('favorites.contact') }}</p>
</div>
<button class="item-remove" @click.stop="removeFavorite($event, item.item_type, item.item_id)">
<span class="material-icons">close</span>
</button>
</div>
</div>
</section>
<!-- BUSINESSES SECTION -->
<section v-if="(selectedFilter === 'all' || selectedFilter === 'businesses') && favoritesStore.businesses.length > 0" class="fav-section">
<div class="section-header">
<span class="material-icons">store</span>
<h2>{{ t('favorites.tabs.businesses') }}</h2>
</div>
<div class="horizontal-scroll">
<div v-for="item in favoritesStore.businesses" :key="item.id" class="fav-card-mini" @click="navigateToItem(item)">
<div class="mini-card-img">
<img :src="getImageUrl(item.item_image)" :alt="item.item_name">
<button class="mini-remove" @click.stop="removeFavorite($event, item.item_type, item.item_id)">
<span class="material-icons">close</span>
</button>
</div>
<div class="mini-card-body">
<h3>{{ item.item_name }}</h3>
<p>{{ t('favorites.viewDetails') }}</p>
</div>
</div>
</div>
</section>
<!-- EVENTS SECTION -->
<section v-if="(selectedFilter === 'all' || selectedFilter === 'coupons') && favoritesStore.coupons.length > 0" class="fav-section">
<div class="section-header">
<span class="material-icons">event</span>
<h2>{{ t('favorites.tabs.coupons') }}</h2>
</div>
<div class="compact-list">
<div v-for="item in favoritesStore.coupons" :key="item.id" class="list-item">
<div class="item-visual thumb">
<img :src="getImageUrl(item.item_image)" :alt="item.item_name">
</div>
<div class="item-details">
<h3>{{ item.item_name }}</h3>
<p>{{ t('favorites.saved') }}</p>
</div>
<button class="item-remove" @click.stop="removeFavorite($event, item.item_type, item.item_id)">
<span class="material-icons">close</span>
</button>
</div>
</div>
</section>
</div>
</div>
</div>
</template>
<style scoped>
.favorites-view {
min-height: 100vh;
background: var(--bg-primary);
padding-bottom: 80px;
box-sizing: border-box;
}
/* Hero Header */
.favorites-header {
position: relative;
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
color: #101820;
padding: 40px 20px 60px;
text-align: center;
overflow: hidden;
}
.header-overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background:
radial-gradient(circle at 20% 50%, rgba(255,255,255,0.2) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(255,255,255,0.2) 0%, transparent 50%);
pointer-events: none;
}
.header-content {
position: relative;
z-index: 1;
max-width: 600px;
margin: 0 auto;
}
.header-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px;
background: rgba(16, 24, 32, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
animation: float 3s ease-in-out infinite;
}
.header-icon .material-icons {
font-size: 40px;
color: #101820;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.header-content h1 {
font-size: 2.5rem;
font-weight: 900;
margin-bottom: 12px;
color: #101820;
}
/* Filter Card Area */
.filters-container-wrapper {
max-width: 1200px;
margin: 30px auto 30px;
padding: 0 20px;
position: relative;
z-index: 10;
}
.filters-card {
background: var(--card-bg);
border-radius: 20px;
padding: 20px;
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
border: 1px solid var(--active-color);
}
.filter-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.filter-group label {
font-weight: 700;
color: var(--text-primary);
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 8px;
}
.filter-group label .material-icons {
font-size: 20px;
color: var(--active-color);
}
.select-wrapper {
position: relative;
}
.filter-select {
width: 100%;
padding: 14px 44px 14px 16px;
border-radius: 14px;
border: 2px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 1rem;
font-weight: 700;
appearance: none;
cursor: pointer;
transition: all 0.3s;
}
.filter-select option {
background: var(--card-bg);
color: var(--text-primary);
padding: 10px;
}
.filter-select:focus {
outline: none;
border-color: var(--active-color);
box-shadow: 0 0 0 4px rgba(254, 231, 21, 0.1);
}
.dropdown-icon {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
pointer-events: none;
}
/* Dashboard Sections */
.sections-list {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
display: flex;
flex-direction: column;
gap: 48px;
}
.fav-section {
scroll-margin-top: 100px;
}
.section-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
color: var(--text-primary);
}
.section-header .material-icons {
color: var(--active-color);
font-size: 28px;
}
.section-header h2 {
font-size: 1.8rem;
font-weight: 900;
margin: 0;
}
/* Compact List Style */
.compact-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.list-item {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 16px;
display: flex;
align-items: center;
gap: 16px;
transition: all 0.3s ease;
cursor: pointer;
}
.list-item:hover {
transform: translateX(10px);
border-color: var(--active-color);
background: var(--hover-bg);
}
.item-visual {
width: 60px;
height: 60px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.item-visual.bg-yellow {
background: var(--active-color);
color: #101820;
}
.item-visual.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-details {
flex: 1;
}
.item-details h3 {
margin: 0;
font-size: 1.15rem;
font-weight: 800;
}
.item-details p {
margin: 4px 0 0;
font-size: 0.9rem;
color: var(--text-secondary);
}
.item-remove {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.item-remove:hover {
background: #ff4757;
color: white;
border-color: #ff4757;
}
/* Horizontal Scroll for Businesses */
.horizontal-scroll {
display: flex;
gap: 16px;
overflow-x: auto;
padding: 4px 4px 20px;
scrollbar-width: none;
}
.horizontal-scroll::-webkit-scrollbar {
display: none;
}
.fav-card-mini {
width: 240px;
flex-shrink: 0;
background: var(--card-bg);
border-radius: 24px;
border: 1px solid var(--border-color);
overflow: hidden;
transition: all 0.3s ease;
cursor: pointer;
}
.fav-card-mini:hover {
transform: translateY(-8px);
border-color: var(--active-color);
}
.mini-card-img {
height: 140px;
position: relative;
overflow: hidden;
}
.mini-card-img img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mini-remove {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0,0,0,0.5);
color: white;
border: none;
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
cursor: pointer;
}
.mini-card-body {
padding: 16px;
}
.mini-card-body h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-card-body p {
margin: 4px 0 0;
font-size: 0.85rem;
color: var(--active-color);
font-weight: 700;
}
/* States */
.empty-state {
text-align: center;
padding: 40px 24px;
background: var(--header-bg);
border-radius: 30px;
border: 2px dashed var(--border-color);
max-width: 500px;
margin: 0 auto;
}
.empty-icon {
width: 100px;
height: 100px;
margin: 0 auto 24px;
background: var(--bg-secondary);
border-radius: 30px;
display: flex;
align-items: center;
justify-content: center;
color: var(--active-color);
opacity: 0.5;
}
.empty-state h3 {
font-size: 1.5rem;
font-weight: 900;
margin-bottom: 12px;
}
.cta-link {
display: inline-flex;
padding: 16px 32px;
background: var(--active-color);
color: #101820;
text-decoration: none;
border-radius: 16px;
font-weight: 900;
margin-top: 24px;
}
@media (max-width: 768px) {
.favorites-header {
padding: 40px 16px 60px;
}
.header-content h1 {
font-size: 2.1rem;
}
.sections-list {
padding: 0 16px;
gap: 32px;
}
.section-header h2 {
font-size: 1.5rem;
}
.list-item {
padding: 12px;
}
.item-visual {
width: 50px;
height: 50px;
}
.item-details h3 {
font-size: 1rem;
}
.fav-card-mini {
width: 200px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,720 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useCouponStore } from '@/stores/coupon'
import { authService } from '@/services/authService'
const router = useRouter()
const couponStore = useCouponStore()
const userName = ref(localStorage.getItem('user_name') || 'Usuario')
const userEmail = ref(localStorage.getItem('user_email') || '')
const userRole = ref(localStorage.getItem('user_role') || 'PASSENGER')
const userPhoto = ref(localStorage.getItem('profile_photo_url') || '')
const showQRModal = ref(false)
const showEditModal = ref(false)
const isUpdating = ref(false)
const selectedCode = ref('')
const selectedTitle = ref('')
// Edit Form
const editForm = ref({
full_name: userName.value,
password: '',
profile_photo: null as File | null
})
const photoPreview = ref(userPhoto.value)
onMounted(async () => {
await couponStore.loadMyCoupons()
// Refresh user data from server to be sure
try {
const freshUser = await authService.getCurrentUser()
userName.value = freshUser.full_name
userEmail.value = freshUser.email
userRole.value = freshUser.role
userPhoto.value = freshUser.profile_photo_url || ''
localStorage.setItem('user_name', freshUser.full_name)
localStorage.setItem('profile_photo_url', freshUser.profile_photo_url || '')
photoPreview.value = userPhoto.value
} catch (e) {
console.error('Failed to refresh user data', e)
}
})
function handleLogout() {
authService.logout()
router.push('/login')
}
function showQR(code: string, title: string) {
selectedCode.value = code
selectedTitle.value = title
showQRModal.value = true
}
function handlePhotoChange(e: Event) {
const target = e.target as HTMLInputElement
if (target.files && target.files[0]) {
const file = target.files[0]
editForm.value.profile_photo = file
photoPreview.value = URL.createObjectURL(file)
}
}
async function handleUpdateProfile() {
isUpdating.value = true
try {
const formData = new FormData()
formData.append('full_name', editForm.value.full_name)
if (editForm.value.password) {
formData.append('password', editForm.value.password)
}
if (editForm.value.profile_photo) {
formData.append('profile_photo', editForm.value.profile_photo)
}
const updatedUser = await authService.updateMe(formData)
// Update local state
userName.value = updatedUser.full_name
userPhoto.value = updatedUser.profile_photo_url || ''
// Update localStorage
localStorage.setItem('user_name', updatedUser.full_name)
localStorage.setItem('profile_photo_url', updatedUser.profile_photo_url || '')
showEditModal.value = false
editForm.value.password = ''
alert('Perfil actualizado correctamente')
} catch (e: any) {
alert('Error al actualizar: ' + (e.response?.data?.detail || e.message))
} finally {
isUpdating.value = false
}
}
function getStatusLabel(status: string) {
switch (status) {
case 'claimed': return 'Pendiente'
case 'redeemed': return 'Canjeado'
case 'expired': return 'Vencido'
default: return status
}
}
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
function getFullUrl(path: string) {
if (!path) return ''
if (path.startsWith('http')) return path
return `${API_URL}${path}`
}
</script>
<template>
<div class="profile-view">
<header class="profile-header">
<div class="profile-info-card">
<div class="avatar-container">
<div v-if="userPhoto" class="avatar-img" :style="{ backgroundImage: `url(${getFullUrl(userPhoto)})` }"></div>
<div v-else class="avatar-placeholder">
<span class="material-icons">person</span>
</div>
<button class="edit-badge" @click="showEditModal = true">
<span class="material-icons">edit</span>
</button>
</div>
<div class="info">
<h1>{{ userName }}</h1>
<p>{{ userEmail }}</p>
<div class="badge-row">
<span class="role-badge">{{ userRole }}</span>
</div>
</div>
<div class="header-actions">
<button class="logout-icon-btn" @click="handleLogout" title="Cerrar Sesión">
<span class="material-icons">logout</span>
</button>
</div>
</div>
</header>
<section class="my-coupons-section">
<div class="section-header">
<h2>Mis Cupones</h2>
<span class="count">{{ couponStore.myCoupons.length }}</span>
</div>
<div v-if="couponStore.myCoupons.length === 0" class="empty-coupons">
<div class="empty-icon-circle">
<span class="material-icons">confirmation_number</span>
</div>
<h3>No tienes cupones</h3>
<p>Explora los beneficios que tenemos para ti por usar SIBU.</p>
<button @click="router.push('/coupons')" class="btn-primary">Ver Ofertas</button>
</div>
<div v-else class="coupons-list">
<div
v-for="userCoupon in couponStore.myCoupons"
:key="userCoupon.id"
:class="['user-coupon-card', userCoupon.status]"
>
<div class="coupon-main">
<div class="coupon-details">
<h3>{{ userCoupon.coupon?.title || 'Cupón' }}</h3>
<p class="biz-name">{{ userCoupon.coupon?.business_name || 'Comercio' }}</p>
<div class="code-row">
<span class="code">{{ userCoupon.redemption_code }}</span>
<span :class="['status-tag', userCoupon.status]">{{ getStatusLabel(userCoupon.status) }}</span>
</div>
</div>
<button
v-if="userCoupon.status === 'claimed'"
class="btn-use"
@click="showQR(userCoupon.redemption_code, userCoupon.coupon?.title || '')"
>
<span class="material-icons">qr_code_2</span>
Ver Código
</button>
</div>
<div class="coupon-footer">
<span v-if="userCoupon.status === 'redeemed'">Usado el: {{ new Date(userCoupon.redeemed_at).toLocaleDateString() }}</span>
<span v-else>Reclamado el: {{ new Date(userCoupon.claimed_at).toLocaleDateString() }}</span>
</div>
</div>
</div>
</section>
<!-- Edit Profile Modal -->
<div v-if="showEditModal" class="modal-overlay" @click.self="showEditModal = false">
<div class="edit-modal">
<div class="modal-header">
<h2>Editar Perfil</h2>
<button class="close-btn" @click="showEditModal = false">
<span class="material-icons">close</span>
</button>
</div>
<form @submit.prevent="handleUpdateProfile" class="edit-form">
<div class="photo-upload-section">
<div class="photo-preview-container">
<div v-if="photoPreview" class="preview-img" :style="{ backgroundImage: `url(${getFullUrl(photoPreview)})` }"></div>
<div v-else class="preview-placeholder">
<span class="material-icons">person</span>
</div>
<label for="photo-input" class="photo-label">
<span class="material-icons">photo_camera</span>
</label>
</div>
<input id="photo-input" type="file" @change="handlePhotoChange" accept="image/*" hidden>
<p class="upload-hint">Foto opcional</p>
</div>
<div class="form-group">
<label>Nombre Completo</label>
<input v-model="editForm.full_name" type="text" placeholder="Tu nombre" required>
</div>
<div class="form-group">
<label>Nueva Contraseña (Opcional)</label>
<input v-model="editForm.password" type="password" placeholder="Mínimo 6 caracteres">
<p class="field-hint">Déjalo en blanco si no quieres cambiarla.</p>
</div>
<div class="modal-actions">
<button type="button" class="btn-cancel" @click="showEditModal = false">Cancelar</button>
<button type="submit" class="btn-save" :disabled="isUpdating">
<span v-if="isUpdating" class="material-icons spin">refresh</span>
{{ isUpdating ? 'Guardando...' : 'Guardar Cambios' }}
</button>
</div>
</form>
</div>
</div>
<!-- QR Modal -->
<div v-if="showQRModal" class="modal-overlay" @click.self="showQRModal = false">
<div class="qr-modal">
<button class="close-modal" @click="showQRModal = false">
<span class="material-icons">close</span>
</button>
<div class="qr-header">
<span class="material-icons">verified</span>
<h3>Cupón de Descuento</h3>
</div>
<p class="promo-title">{{ selectedTitle }}</p>
<div class="qr-content">
<div class="qr-placeholder">
<span class="material-icons">qr_code_2</span>
</div>
<div class="redemption-box">
<p>CÓDIGO DE REDENCIÓN</p>
<code class="big-code">{{ selectedCode }}</code>
</div>
</div>
<p class="qr-instructions">Muestra este código al encargado del establecimiento para validar tu promoción.</p>
<button class="btn-done" @click="showQRModal = false">Entendido</button>
</div>
</div>
</div>
</template>
<style scoped>
.profile-view {
padding: 1.5rem;
background: var(--bg-primary);
min-height: 100%;
}
.profile-header {
margin-bottom: 2rem;
}
.profile-info-card {
background: var(--card-bg);
border-radius: 28px;
padding: 1.5rem;
display: flex;
align-items: center;
gap: 1.5rem;
box-shadow: 0 10px 30px var(--shadow);
border: 1px solid var(--border-color);
position: relative;
overflow: hidden;
}
.avatar-container {
position: relative;
}
.avatar-img, .avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 24px;
background-size: cover;
background-position: center;
border: 3px solid var(--header-bg);
}
.avatar-placeholder {
background: var(--bg-secondary);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-placeholder .material-icons {
font-size: 3rem;
}
.edit-badge {
position: absolute;
bottom: -5px;
right: -5px;
background: var(--text-primary);
color: var(--bg-primary);
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.edit-badge .material-icons { font-size: 1rem; }
.info h1 { font-size: 1.4rem; margin-bottom: 2px; }
.info p { color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 8px; }
.role-badge {
background: var(--active-bg);
color: var(--active-color);
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.header-actions {
margin-left: auto;
}
.logout-icon-btn {
background: #ffebf0;
color: #d32f2f;
border: none;
border-radius: 12px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.logout-icon-btn:hover { background: #d32f2f; color: white; }
.section-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.section-header h2 { font-size: 1.25rem; }
.section-header .count {
background: var(--text-primary);
color: var(--bg-primary);
padding: 2px 10px;
border-radius: 20px;
font-weight: 800;
font-size: 0.8rem;
}
.empty-coupons {
padding: 4rem 2rem;
text-align: center;
background: var(--card-bg);
border-radius: 24px;
border: 2px dashed var(--border-color);
}
.empty-icon-circle {
width: 70px;
height: 70px;
background: var(--bg-secondary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.5rem;
color: var(--text-secondary);
}
.empty-icon-circle .material-icons { font-size: 2.5rem; }
.btn-primary {
margin-top: 1.5rem;
background: var(--text-primary);
color: var(--bg-primary);
border: none;
padding: 12px 24px;
border-radius: 16px;
font-weight: 700;
cursor: pointer;
}
.coupons-list {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.user-coupon-card {
background: var(--card-bg);
border-radius: 20px;
border: 1px solid var(--border-color);
overflow: hidden;
box-shadow: 0 4px 15px var(--shadow);
transition: transform 0.2s;
}
.user-coupon-card:hover { transform: scale(1.02); }
.coupon-main {
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.coupon-details h3 { font-size: 1.1rem; margin-bottom: 4px; }
.biz-name { font-size: 0.9rem; color: var(--active-color); font-weight: 700; }
.code-row {
margin-top: 12px;
display: flex;
align-items: center;
gap: 12px;
}
.code {
font-family: 'Courier New', Courier, monospace;
background: var(--bg-secondary);
padding: 6px 12px;
border-radius: 8px;
font-weight: 800;
font-size: 1rem;
border: 1px dashed var(--border-color);
}
.status-tag {
font-size: 0.7rem;
padding: 4px 10px;
border-radius: 8px;
font-weight: 800;
text-transform: uppercase;
}
.status-tag.claimed { background: #e3f2fd; color: #1976d2; }
.status-tag.redeemed { background: #e8f5e9; color: #2e7d32; opacity: 0.7; }
.btn-use {
background: var(--header-bg);
color: var(--header-text);
border: none;
padding: 12px 18px;
border-radius: 14px;
font-weight: 800;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
}
.coupon-footer {
padding: 10px 1.5rem;
background: var(--bg-secondary);
font-size: 0.8rem;
color: var(--text-secondary);
border-top: 1px solid var(--border-color);
font-weight: 600;
}
/* Modal Base */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1.5rem;
}
/* Edit Modal */
.edit-modal {
background: var(--card-bg);
width: 100%;
max-width: 450px;
border-radius: 32px;
padding: 2rem;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.close-btn, .close-modal {
background: var(--bg-secondary);
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.photo-upload-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 2rem;
}
.photo-preview-container {
position: relative;
margin-bottom: 1rem;
}
.preview-img, .preview-placeholder {
width: 100px;
height: 100px;
border-radius: 30px;
background-size: cover;
background-position: center;
border: 4px solid var(--header-bg);
}
.preview-placeholder {
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
}
.preview-placeholder .material-icons { font-size: 4rem; color: var(--text-secondary); }
.photo-label {
position: absolute;
bottom: -5px;
right: -5px;
background: var(--active-color);
color: white;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
font-size: 0.85rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--text-secondary);
}
.form-group input {
width: 100%;
padding: 12px 16px;
border-radius: 14px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 1rem;
}
.field-hint, .upload-hint {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 4px;
}
.btn-save {
flex: 2;
background: var(--text-primary);
color: var(--bg-primary);
border: none;
padding: 14px;
border-radius: 16px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-cancel {
flex: 1;
background: var(--bg-secondary);
color: var(--text-primary);
border: none;
padding: 14px;
border-radius: 16px;
font-weight: 700;
cursor: pointer;
}
/* QR Modal Enhanced */
.qr-modal {
background: var(--card-bg);
width: 100%;
max-width: 400px;
border-radius: 40px;
padding: 2.5rem;
text-align: center;
position: relative;
}
.qr-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 1rem;
}
.qr-header .material-icons { color: #4caf50; font-size: 3rem; }
.qr-header h3 { font-size: 1.5rem; }
.promo-title {
color: var(--active-color);
font-weight: 800;
margin-bottom: 2rem;
font-size: 1.1rem;
}
.qr-content {
background: #f8f9fa;
padding: 2rem;
border-radius: 30px;
margin-bottom: 2rem;
border: 1px solid #eee;
}
.qr-placeholder {
width: 120px;
height: 120px;
background: white;
margin: 0 auto 1.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 20px;
}
.qr-placeholder .material-icons { font-size: 6rem; color: #222; }
.redemption-box p { font-size: 0.7rem; font-weight: 800; color: #888; letter-spacing: 1px; margin-bottom: 4px; }
.big-code {
font-size: 1.8rem;
font-weight: 900;
color: #111;
letter-spacing: 3px;
}
.qr-instructions {
font-size: 0.9rem;
line-height: 1.4;
color: var(--text-secondary);
margin-bottom: 2rem;
}
.btn-done {
width: 100%;
padding: 16px;
border-radius: 20px;
background: var(--text-primary);
color: var(--bg-primary);
border: none;
font-weight: 800;
cursor: pointer;
}
.spin { animation: spin 1s linear infinite; }
@keyframes spin { 100% { transform: rotate(360deg); } }
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,288 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRouteStore } from '@/stores/route'
import { analyticsService } from '@/services/analyticsService'
import FavoriteButton from '@/components/FavoriteButton.vue'
const router = useRouter()
const routeStore = useRouteStore()
const originSearch = ref('')
const destinationSearch = ref('')
onMounted(async () => {
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'Routes' })
await routeStore.loadRoutes()
})
const handleSearch = async () => {
await routeStore.loadRoutes({
originCity: originSearch.value,
destinationCity: destinationSearch.value
})
}
const goToSchedules = (route: any) => {
analyticsService.logEvent({
event_name: 'route_selected',
item_id: route.name,
properties: { route_id: route.id }
})
routeStore.selectRoute(route.id, route.name)
router.push('/schedules')
}
</script>
<template>
<div class="routes-view">
<div class="header">
<h1>Búsqueda de Rutas</h1>
<p>Encuentra tu próximo viaje fácilmente</p>
</div>
<div class="search-box">
<div class="input-group">
<span class="material-icons">location_on</span>
<input
v-model="originSearch"
placeholder="Origen (Ciudad)"
@keyup.enter="handleSearch"
>
</div>
<div class="divider">
<span class="material-icons">swap_vert</span>
</div>
<div class="input-group">
<span class="material-icons">flag</span>
<input
v-model="destinationSearch"
placeholder="Destino (Ciudad)"
@keyup.enter="handleSearch"
>
</div>
<button @click="handleSearch" class="search-btn">
<span class="material-icons">search</span>
Buscar Rutas
</button>
</div>
<div v-if="routeStore.isLoadingRoutes" class="loading">
<div class="spinner"></div>
<p>Buscando mejores rutas...</p>
</div>
<div v-else-if="routeStore.allRoutes.length === 0" class="no-results">
<span class="material-icons">sentiment_dissatisfied</span>
<p>No encontramos rutas que coincidan con tu búsqueda.</p>
<button @click="originSearch = ''; destinationSearch = ''; handleSearch()" class="reset-btn">Ver todas las rutas</button>
</div>
<div v-else class="routes-list">
<div
v-for="route in routeStore.allRoutes"
:key="route.id"
class="route-card"
@click="goToSchedules(route)"
>
<div class="route-header">
<div class="route-name">
<span class="dot" :style="{ backgroundColor: route.color || '#fee715' }"></span>
<h3>{{ route.name }}</h3>
</div>
<div class="route-actions">
<FavoriteButton
item-type="route"
:item-id="route.id"
:item-name="route.name"
/>
<span class="material-icons chevron">chevron_right</span>
</div>
</div>
<div class="route-details">
<div class="city-flow">
<span>{{ route.origin_city }}</span>
<span class="material-icons">arrow_forward</span>
<span>{{ route.destination_city }}</span>
</div>
<p v-if="route.distance_km" class="meta">
{{ route.distance_km }} km {{ route.estimated_duration_minutes }} min aprox.
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.routes-view {
padding: 24px;
background: var(--bg-primary);
min-height: 100vh;
}
.header {
margin-bottom: 32px;
}
.header h1 {
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 4px;
}
.header p {
color: var(--text-secondary);
font-size: 16px;
}
.search-box {
background: var(--card-bg);
border-radius: 24px;
padding: 24px;
box-shadow: 0 10px 30px var(--shadow);
margin-bottom: 32px;
}
.input-group {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-secondary);
padding: 12px 20px;
border-radius: 16px;
}
.input-group input {
border: none;
background: transparent;
width: 100%;
font-size: 16px;
font-weight: 500;
outline: none;
}
.divider {
display: flex;
justify-content: center;
margin: 8px 0;
color: var(--text-secondary);
}
.search-btn {
width: 100%;
margin-top: 20px;
background: var(--header-bg);
color: var(--header-text);
border: none;
padding: 16px;
border-radius: 16px;
font-weight: 800;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: transform 0.2s;
}
.search-btn:active {
transform: scale(0.98);
}
.routes-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.route-card {
background: var(--card-bg);
border-radius: 20px;
padding: 20px;
cursor: pointer;
box-shadow: 0 4px 15px var(--shadow);
transition: transform 0.2s, box-shadow 0.2s;
}
.route-card:hover {
transform: translateY(-4px);
box-shadow: 0 10px 25px rgba(0,0,0,0.08);
}
.route-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.route-name {
display: flex;
align-items: center;
gap: 12px;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.route-name h3 {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.city-flow {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.route-actions {
display: flex;
align-items: center;
gap: 12px;
}
.chevron {
color: #ced4da;
}
.loading, .no-results {
text-align: center;
padding: 60px 20px;
color: #666;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #fee715;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.reset-btn {
margin-top: 16px;
background: transparent;
border: 1px solid #dee2e6;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,472 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useScheduleStore } from '@/stores/schedule'
import { useRouteStore } from '@/stores/route'
import { formatTo12Hour } from '@/utils/timeFormatter'
import { analyticsService } from '@/services/analyticsService'
import { useRouter, useRoute } from 'vue-router'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const scheduleStore = useScheduleStore()
const routeStore = useRouteStore()
const showRouteDropdown = ref(false)
const routeCardRef = ref<HTMLElement | null>(null)
// Close dropdown when clicking outside
function handleClickOutside(event: MouseEvent) {
if (routeCardRef.value && !routeCardRef.value.contains(event.target as Node)) {
showRouteDropdown.value = false
}
}
onMounted(async () => {
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'Schedules' })
document.addEventListener('click', handleClickOutside)
await routeStore.loadRoutes()
// Point 1: Smart synchronization from MapView
const queryRouteId = route.query.routeId as string
if (queryRouteId) {
const foundRoute = routeStore.allRoutes.find(r => r.id === queryRouteId)
if (foundRoute) {
// FIX: Use sync function to avoid redirect loop back to map
syncRouteSelection(foundRoute.id, foundRoute.name)
}
}
})
const unwatchQuery = watch(
() => route.query.routeId,
(newRouteId) => {
if (newRouteId) {
const foundRoute = routeStore.allRoutes.find(r => r.id === newRouteId as string)
if (foundRoute) {
syncRouteSelection(foundRoute.id, foundRoute.name)
}
}
}
)
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
unwatchQuery()
})
function syncRouteSelection(routeId: string, routeName: string) {
routeStore.selectRoute(routeId, routeName)
scheduleStore.loadRouteSchedules(routeId)
showRouteDropdown.value = false
}
function selectRouteAndClose(routeId: string, routeName: string) {
analyticsService.logEvent({
event_name: 'schedule_viewed',
item_id: routeName,
properties: { route_id: routeId }
})
routeStore.selectRoute(routeId, routeName)
scheduleStore.loadRouteSchedules(routeId)
showRouteDropdown.value = false
}
function goToMap() {
if (routeStore.selectedRouteId) {
router.push({
path: '/map',
query: { routeId: routeStore.selectedRouteId }
})
}
}
function clearRouteAndClose() {
routeStore.clearSelection()
scheduleStore.schedules = []
showRouteDropdown.value = false
}
</script>
<template>
<div class="schedules-view">
<h1>{{ t('schedules.title') }}</h1>
<div v-if="routeStore.isLoadingRoutes">
<p>{{ t('schedules.loadingRoutes') }}</p>
</div>
<div v-else-if="routeStore.allRoutes.length === 0">
<p>{{ t('schedules.noRoutesAvailable') }}</p>
</div>
<div v-else>
<!-- Route selector card (same style as MapView) -->
<div v-if="routeStore.allRoutes.length > 0" class="route-card" ref="routeCardRef">
<div class="route-card-content" @click.stop="showRouteDropdown = !showRouteDropdown">
<span class="material-icons route-icon">route</span>
<div class="route-info">
<div v-if="routeStore.selectedRouteId && routeStore.selectedRouteName" class="route-name">
{{ t('schedules.route') }}: {{ routeStore.selectedRouteName }}
</div>
<div v-else class="route-name">{{ t('schedules.selectRoute') }}</div>
<div v-if="routeStore.selectedRouteId && scheduleStore.schedules.length > 0" class="route-stops">
{{ scheduleStore.schedules.length }} {{ t('schedules.schedules') }}
</div>
</div>
<span class="material-icons arrow-icon" :class="{ 'rotated': showRouteDropdown }">
keyboard_arrow_down
</span>
</div>
<!-- Dropdown menu -->
<div v-if="showRouteDropdown" class="route-dropdown" @click.stop>
<div
v-for="route in routeStore.allRoutes"
:key="route.id"
class="route-option"
:class="{ 'selected': route.id === routeStore.selectedRouteId }"
@click="selectRouteAndClose(route.id, route.name)"
>
{{ route.name }}
</div>
<div
v-if="routeStore.selectedRouteId"
class="route-option clear-option"
@click="clearRouteAndClose"
>
{{ t('common.clearSelection') }}
</div>
</div>
</div>
<div v-if="routeStore.selectedRouteId" class="schedules-content">
<div class="schedules-header">
<h2 v-if="routeStore.selectedRouteName">{{ routeStore.selectedRouteName }}</h2>
<button class="view-route-btn" @click="goToMap">
<span class="material-icons">map</span>
Ver ruta
</button>
</div>
<div v-if="scheduleStore.isLoading" class="schedules-loading">
<span class="material-icons spin">refresh</span>
<p>Cargando horarios...</p>
</div>
<div v-else-if="scheduleStore.error" class="schedules-error">
<p>{{ scheduleStore.error }}</p>
</div>
<div v-else-if="scheduleStore.schedules.length > 0">
<ul class="schedule-list">
<li v-for="schedule in scheduleStore.schedules" :key="schedule.id" class="schedule-item">
<div class="schedule-item-info">
<span class="material-icons">schedule</span>
<span class="departure-time">{{ formatTo12Hour(schedule.departure_time) }}</span>
</div>
<span class="schedule-type">{{ schedule.schedule_type }}</span>
</li>
</ul>
</div>
<div v-else class="schedules-empty">
<span class="material-icons">event_busy</span>
<p>No hay horarios disponibles para esta ruta.</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.schedules-view {
min-height: 100vh;
position: relative;
padding: 1.5rem 1rem 150px;
color: var(--text-primary);
overflow-x: hidden;
background: var(--bg-primary) !important;
}
/* Asegurar que el contenido flote sobre el fondo */
.schedules-view > * {
position: relative;
z-index: 1;
}
.schedules-view h1 {
font-size: 2.2rem;
font-weight: 800;
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
margin-bottom: 2rem;
text-align: center;
}
.route-card {
margin-bottom: 2rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.route-card-content {
display: flex;
align-items: center;
gap: 12px;
padding: 18px;
background: var(--card-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: var(--shadow);
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.3s ease;
}
.route-card-content:hover {
transform: translateY(-2px);
box-shadow: 0 12px 30px rgba(0,0,0,0.2);
}
.route-card-content:active {
transform: scale(0.98);
}
.route-icon {
color: var(--active-color);
font-size: 28px;
}
.route-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.route-name {
font-size: 1.1rem;
font-weight: 700;
color: var(--text-primary);
}
.route-stops {
font-size: 0.9rem;
color: var(--text-secondary);
font-weight: 500;
}
.arrow-icon {
color: var(--text-secondary);
font-size: 24px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.arrow-icon.rotated {
transform: rotate(180deg);
color: var(--active-color);
}
.route-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 10px;
background: var(--card-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: var(--shadow);
max-height: 250px;
overflow-y: auto;
z-index: 100;
}
.route-option {
padding: 14px 20px;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
font-weight: 500;
}
.route-option:last-child {
border-bottom: none;
}
.route-option:hover {
background-color: var(--hover-bg);
padding-left: 24px;
}
.route-option.selected {
background-color: var(--active-bg);
color: var(--active-color);
font-weight: 700;
}
.route-option.clear-option {
color: #ef4444; /* Rojo para limpiar */
font-weight: 600;
text-align: center;
}
.schedules-content {
margin-top: 1.5rem;
background: var(--card-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
padding: 24px;
border: 1px solid var(--border-color);
border-radius: 24px;
box-shadow: var(--shadow);
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.schedules-content h2 {
text-align: center;
margin-bottom: 1.5rem;
color: var(--active-color);
font-size: 1.5rem;
font-weight: 800;
}
.schedules-loading, .schedules-empty, .schedules-error {
text-align: center;
padding: 40px 20px;
}
.spin {
animation: spin 1s linear infinite;
font-size: 3rem;
color: var(--active-color);
margin-bottom: 16px;
display: inline-block;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.schedule-list {
list-style: none;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.schedule-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: var(--bg-secondary);
border-radius: 16px;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}
.schedule-item:hover {
transform: translateY(-3px) scale(1.02);
border-color: var(--active-color);
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
}
.schedule-item-info {
display: flex;
align-items: center;
gap: 12px;
}
.schedule-item .material-icons {
color: var(--active-color);
}
.departure-time {
font-size: 1.3rem;
font-weight: 800;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.schedule-type {
font-size: 0.75rem;
font-weight: 700;
padding: 5px 10px;
background: var(--active-bg);
color: var(--active-color);
border-radius: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.schedules-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
gap: 16px;
flex-wrap: wrap;
}
.schedules-header h2 {
margin-bottom: 0 !important;
text-align: left !important;
flex: 1;
}
.view-route-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: #FEE715;
color: #101820;
border: none;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.view-route-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
filter: brightness(1.1);
}
.view-route-btn:active {
transform: translateY(0);
}
@media (max-width: 600px) {
.schedules-header {
flex-direction: column;
text-align: center;
}
.schedules-header h2 {
text-align: center !important;
}
.view-route-btn {
width: 100%;
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,241 @@
<template>
<div class="splash-screen">
<div class="splash-content">
<!-- Logo with animation -->
<div class="logo-container" :class="{ 'logo-visible': logoVisible }">
<div class="logo-box">
<img src="/icon-192.png" alt="SIBU" class="logo-icon" />
</div>
</div>
<!-- Loading indicator -->
<div v-if="showLoading" class="loading-container" :class="{ 'loading-visible': loadingVisible }">
<div class="spinner"></div>
<p class="status-message">{{ statusMessage }}</p>
</div>
</div>
<!-- Version info -->
<div class="version-info">
<p class="app-subtitle">Transporte Público Boquete</p>
<p class="app-version">Versión 1.2.0</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRouteStore } from '@/stores/route'
import { useBusStopStore } from '@/stores/busStop'
const router = useRouter()
const routeStore = useRouteStore()
const busStopStore = useBusStopStore()
const logoVisible = ref(false)
const showLoading = ref(false)
const loadingVisible = ref(false)
const statusMessage = ref('Iniciando SIBU...')
onMounted(async () => {
// Start logo animation
logoVisible.value = true
// Show loading indicator
showLoading.value = true
loadingVisible.value = true
// Perform initialization tasks with a safety timeout
const initTimeout = setTimeout(() => {
console.warn('Initialization taking too long, forcing navigation...')
statusMessage.value = 'Iniciando de todas formas...'
navigate()
}, 5000)
try {
await performInitializationTasks()
clearTimeout(initTimeout)
navigate()
} catch (error) {
console.error('Initialization failed', error)
clearTimeout(initTimeout)
navigate()
}
})
function navigate() {
// Navigate based on role
const role = localStorage.getItem('user_role')?.toUpperCase()
if (role === 'ADMIN') {
router.replace('/admin')
} else if (role === 'DRIVER') {
router.replace('/driver')
} else if (role === 'PROMOTER') {
router.replace('/promoter')
} else {
router.replace('/map')
}
}
async function performInitializationTasks() {
// Task 1: Check connection and load routes
statusMessage.value = 'Cargando datos de rutas...'
try {
await routeStore.loadRoutes()
} catch (error) {
console.error('Error loading routes:', error)
}
// Task 2: Load bus stops
statusMessage.value = 'Cargando paradas...'
try {
await busStopStore.loadBusStops()
} catch (error) {
console.error('Error loading bus stops:', error)
}
// Task 3: Prepared
statusMessage.value = 'Listo para usar'
}
</script>
<style scoped>
.splash-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background-color: #101820;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 9999;
}
.splash-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
}
.logo-container {
opacity: 0;
transform: scale(0.8);
transition: opacity 0.6s ease-out, transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.logo-container.logo-visible {
opacity: 1;
transform: scale(1);
}
.logo-box {
width: 140px;
height: 140px;
background-color: #fee715;
border-radius: 28px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
}
.logo-icon {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.loading-container {
margin-top: 48px;
display: flex;
flex-direction: column;
align-items: center;
opacity: 0;
transition: opacity 0.8s ease-in;
}
.loading-container.loading-visible {
opacity: 1;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(254, 231, 21, 0.3);
border-top-color: #fee715;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.status-message {
margin-top: 16px;
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
text-align: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
.version-info {
position: absolute;
bottom: 64px;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.app-subtitle {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
text-align: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
.app-version {
color: rgba(255, 255, 255, 0.4);
font-size: 10px;
text-align: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.logo-box {
width: 120px;
height: 120px;
border-radius: 24px;
}
.logo-icon {
width: 85px;
height: 85px;
}
.loading-container {
margin-top: 40px;
}
.version-info {
bottom: 48px;
}
}
</style>

View File

@ -0,0 +1,482 @@
<template>
<div class="strategic-analytics">
<div class="header-section">
<div class="top-row">
<button class="download-btn" @click="generateReport">
<span class="material-icons">description</span>
Descargar Informe
</button>
<div class="badge">INTELIGENCIA ESTRATÉGICA</div>
</div>
<h1>Centro de Operaciones</h1>
<p class="subtitle">Análisis segmentado de rendimiento SIBU</p>
</div>
<!-- TACTICAL TAB SELECTOR -->
<div class="tabs-control">
<button
class="tab-btn"
:class="{ active: activeTab === 'overview' }"
@click="activeTab = 'overview'"
>
<span class="material-icons">dashboard</span>
Visión General
</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'transport' }"
@click="activeTab = 'transport'"
>
<span class="material-icons">directions_bus</span>
Logística de Transporte
</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'commerce' }"
@click="activeTab = 'commerce'"
>
<span class="material-icons">storefront</span>
Inteligencia Comercial
</button>
</div>
<div v-if="loading" class="loading-state">
<span class="material-icons spin">sync</span>
<p>Sincronizando con la red...</p>
</div>
<template v-else>
<!-- SECTION 1: OVERVIEW -->
<div v-if="activeTab === 'overview'" class="tab-content animate-fade">
<div class="dashboard-layout">
<div class="main-content">
<div class="kpi-grid">
<div class="kpi-card user-active">
<div class="kpi-icon"><span class="material-icons">person</span></div>
<div class="kpi-data">
<span class="kpi-value">{{ stats.users?.registered_active || 0 }}</span>
<span class="kpi-label">Usuarios Registrados Activos</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon"><span class="material-icons">analytics</span></div>
<div class="kpi-data">
<span class="kpi-value">{{ totalInteractionCount }}</span>
<span class="kpi-label">Interacciones Totales Hoy</span>
</div>
</div>
</div>
<section class="analysis-section mini">
<div class="section-header">
<span class="material-icons">schedule</span>
<h2>Mapa de Calor Horario</h2>
</div>
<div class="chart-container large">
<Line :data="usageChartData" :options="usageChartOptions" />
</div>
</section>
</div>
<aside class="side-info">
<div class="info-box">
<span class="material-icons">groups</span>
<h4>Control de Tráfico</h4>
<p>Esta sección muestra la salud general de la app. Si la línea de invitados supera por mucho a la de registrados, es momento de lanzar una campaña de fidelización.</p>
</div>
</aside>
</div>
</div>
<!-- SECTION 2: TRANSPORT -->
<div v-if="activeTab === 'transport'" class="tab-content animate-fade">
<div class="dashboard-layout">
<div class="main-content">
<!-- RUTAS -->
<section class="analysis-section">
<div class="section-header">
<span class="material-icons">bar_chart</span>
<h2>Rutas Turísticas más Consultadas</h2>
</div>
<div class="chart-container">
<Bar :data="routesChartData" :options="routesChartOptions" />
</div>
</section>
<!-- CASETAS -->
<section class="analysis-section">
<div class="section-header">
<span class="material-icons">location_on</span>
<h2>Puntos de Interés: Casetas (Paradas)</h2>
</div>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>ID Caseta</th>
<th>Peticiones</th>
<th>Popularidad</th>
</tr>
</thead>
<tbody>
<tr v-for="stop in stats.top_stops" :key="stop.id">
<td class="id-cell"># {{ stop.id }}</td>
<td>{{ stop.count }}</td>
<td>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: (stop.count / maxStopCount * 100) + '%' }"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- RENDIMIENTO SHUTTLES -->
<section class="analysis-section">
<div class="section-header">
<span class="material-icons">trending_up</span>
<h2>Tasa de Reservación (Shuttles)</h2>
</div>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Ruta</th>
<th>Conversión</th>
<th>Ratio</th>
</tr>
</thead>
<tbody>
<tr v-for="(data, id) in stats.shuttles" :key="id">
<td class="id-cell">{{ id }}</td>
<td>{{ calculateConversion(data.views, data.contacts) }}%</td>
<td>
<div class="mini-bar"><div class="fill" :style="{ width: calculateConversion(data.views, data.contacts) + '%' }"></div></div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<aside class="side-info">
<div class="info-box accent">
<span class="material-icons">local_shipping</span>
<h4>Optimización de Logística</h4>
<p>Identifique paradas saturadas para coordinar con los conductores. Las rutas con conversión mayor al 15% son candidatas para ser rutas 'Express'.</p>
</div>
</aside>
</div>
</div>
<!-- SECTION 3: COMMERCE -->
<div v-if="activeTab === 'commerce'" class="tab-content animate-fade">
<div class="dashboard-layout">
<div class="main-content">
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-icon promo"><span class="material-icons">confirmation_number</span></div>
<div class="kpi-data">
<span class="kpi-value">{{ stats.summary?.total_promo_clicks || 0 }}</span>
<span class="kpi-label">Cupones Activados</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon biz"><span class="material-icons">storefront</span></div>
<div class="kpi-data">
<span class="kpi-value">{{ stats.summary?.total_biz_views || 0 }}</span>
<span class="kpi-label">Visitas a Negocios</span>
</div>
</div>
</div>
<section class="analysis-section">
<div class="section-header">
<span class="material-icons">ads_click</span>
<h2>Impacto de Aliados Comerciales</h2>
</div>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Negocio</th>
<th>Visitas</th>
<th>Cupones</th>
<th>Salud</th>
</tr>
</thead>
<tbody>
<tr v-for="(data, id) in stats.businesses" :key="id">
<td class="id-cell">{{ id }}</td>
<td>{{ data.views }}</td>
<td>{{ data.promos }}</td>
<td>
<span class="status-pill" :class="getHealthClass(calculateConversion(data.views, data.promos))">
{{ getHealthLabel(calculateConversion(data.views, data.promos)) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<aside class="side-info">
<div class="info-box">
<span class="material-icons">shopping_bag</span>
<h4>Retorno Comercial</h4>
<p>Analice qué negocios están monetizando mejor el tráfico de SIBU. Use estos datos para ofrecer espacios publicitarios premium a los negocios con salud 'Baja'.</p>
</div>
</aside>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { apiClient } from '@/services/apiClient';
import { Bar, Line } from 'vue-chartjs';
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, PointElement, LineElement } from 'chart.js';
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, PointElement, LineElement);
const loading = ref(true);
const activeTab = ref('overview');
const stats = ref<any>({
shuttles: {},
businesses: {},
top_stops: [],
users: { registered_active: 0, patterns: { registered: {}, guests: {} } },
summary: { total_shuttle_contacts: 0, total_promo_clicks: 0, total_biz_views: 0 }
});
const totalInteractionCount = computed(() => {
const s = stats.value.summary;
return (s.total_shuttle_contacts || 0) + (s.total_promo_clicks || 0) + (s.total_biz_views || 0);
});
const maxStopCount = computed(() => {
if (!stats.value.top_stops.length) return 1;
return Math.max(...stats.value.top_stops.map((s: any) => s.count));
});
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
const generateReport = async () => {
const loadingNotify = ref(true); // Podríamos añadir un pequeño indicator de "Generando..."
const date = new Date().toLocaleDateString('es-ES', { month: 'long', year: 'numeric' });
const doc = new jsPDF('p', 'mm', 'a4');
const pageWidth = doc.internal.pageSize.getWidth();
// 1. ENCABEZADO STARK STYLE
doc.setFillColor(30, 41, 59); // Color oscuro SIBU
doc.rect(0, 0, pageWidth, 40, 'F');
doc.setTextColor(254, 231, 21); // Amarillo Activo
doc.setFontSize(22);
doc.setFont('helvetica', 'bold');
doc.text('SIBU COMMAND CENTER', 15, 20);
doc.setTextColor(255, 255, 255);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`INFORME DE INTELIGENCIA ESTRATÉGICA - ${date.toUpperCase()}`, 15, 30);
doc.text(`Generado el: ${new Date().toLocaleString()}`, pageWidth - 15, 30, { align: 'right' });
let cursorY = 55;
// 2. RESUMEN EJECUTIVO
doc.setTextColor(30, 41, 59);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('1. RESUMEN DEL ECOSISTEMA', 15, cursorY);
cursorY += 10;
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
const summaryText = `Durante el periodo actual, se han detectado ${stats.value.users?.registered_active || 0} usuarios registrados activos. Las interacciones totales en la red ascienden a ${totalInteractionCount.value}, demostrando un flujo de actividad estable.`;
const splitSummary = doc.splitTextToSize(summaryText, pageWidth - 30);
doc.text(splitSummary, 15, cursorY);
cursorY += splitSummary.length * 7;
// 3. CAPTURA DE GRÁFICOS (Solo si están visibles o los forzamos)
// Nota: html2canvas captura el DOM. Intentaremos capturar los contenedores de las gráficas
const charts = document.querySelectorAll('.chart-container');
if (charts.length > 0) {
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('2. ANÁLISIS VISUAL DE TENDENCIAS', 15, cursorY);
cursorY += 10;
for (const chart of Array.from(charts).slice(0, 2)) {
if (cursorY > 220) { doc.addPage(); cursorY = 20; }
const canvas = await html2canvas(chart as HTMLElement, { backgroundColor: '#1e293b' });
const imgData = canvas.toDataURL('image/png');
doc.addImage(imgData, 'PNG', 15, cursorY, pageWidth - 30, 60);
cursorY += 70;
}
}
// 4. TABLAS DE DATOS (TRANSPORTE & CASETAS)
if (cursorY > 200) { doc.addPage(); cursorY = 20; }
doc.setTextColor(30, 41, 59);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('3. LOGÍSTICA Y MOVILIDAD TÁCTICA', 15, cursorY);
cursorY += 10;
doc.setFontSize(10);
doc.text('Top 5 Casetas con más concurrencia:', 15, cursorY);
cursorY += 7;
stats.value.top_stops.slice(0, 5).forEach((stop: any) => {
doc.text(`- Caseta #${stop.id}: ${stop.count} peticiones directas detactadas.`, 20, cursorY);
cursorY += 6;
});
// 5. INTELIGENCIA COMERCIAL
cursorY += 10;
if (cursorY > 240) { doc.addPage(); cursorY = 20; }
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('4. IMPACTO COMERCIAL (ALIADOS)', 15, cursorY);
cursorY += 10;
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.text(`Promociones activadas: ${stats.value.summary?.total_promo_clicks || 0} veces.`, 15, cursorY);
cursorY += 6;
doc.text(`Interés en perfiles de negocio: ${stats.value.summary?.total_biz_views || 0} visitas registradas.`, 15, cursorY);
// FOOTER
const totalPages = doc.internal.pages.length - 1;
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setTextColor(150);
doc.text(`SIBU Command Center - Página ${i} de ${totalPages} - Confidencial Admin`, pageWidth / 2, 285, { align: 'center' });
}
doc.save(`Informe_Estrategico_SIBU_${date.replace(/ /g, '_')}.pdf`);
};
// CHARTS CONFIGURATION (MISMOS DATOS QUE ANTES)
const usageChartData = computed(() => {
const hours = Array.from({ length: 24 }, (_, i) => i);
return {
labels: hours.map(h => `${h}:00`),
datasets: [
{ label: 'Registrados', data: hours.map(h => stats.value.users.patterns.registered[h] || 0), borderColor: '#fee715', backgroundColor: 'rgba(254, 231, 21, 0.2)', tension: 0.4, fill: true },
{ label: 'Invitados', data: hours.map(h => stats.value.users.patterns.guests[h] || 0), borderColor: '#64748b', backgroundColor: 'rgba(100, 116, 139, 0.1)', tension: 0.4, fill: true }
]
};
});
const routesChartData = computed(() => {
const routes = stats.value.shuttles || {};
const labels = Object.keys(routes);
return {
labels: labels.slice(0, 8),
datasets: [{ label: 'Consultas', data: labels.slice(0, 8).map(l => routes[l].views), backgroundColor: '#fee715', borderRadius: 10 }]
};
});
const usageChartOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#cbd5e1' } } }, scales: { y: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#64748b' } }, x: { ticks: { color: '#64748b' } } } };
const routesChartOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#64748b' } }, x: { ticks: { color: '#64748b' } } } };
const calculateConversion = (views: number, actions: number) => (views > 0 ? ((actions / views) * 100).toFixed(1) : 0);
const getHealthClass = (rate: any) => (parseFloat(rate) > 20 ? 'excellent' : parseFloat(rate) > 10 ? 'good' : 'low');
const getHealthLabel = (rate: any) => (parseFloat(rate) > 20 ? 'Alta' : parseFloat(rate) > 10 ? 'Media' : 'Baja');
onMounted(async () => {
try {
const response = await apiClient.get('/api/analytics/strategic');
stats.value = response.data;
} catch (error) { console.error(error); } finally { loading.value = false; }
});
</script>
<style scoped>
.strategic-analytics { padding: 40px 24px 120px; max-width: 1350px; margin: 0 auto; color: var(--text-primary); }
.header-section { margin-bottom: 30px; }
.top-row { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.download-btn {
background: rgba(254, 231, 21, 0.1);
border: 1px solid var(--active-color);
color: var(--active-color);
padding: 8px 16px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
font-weight: 800;
cursor: pointer;
transition: all 0.3s;
}
.download-btn:hover {
background: var(--active-color);
color: #101820;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(254, 231, 21, 0.3);
}
h1 { font-size: 2.2rem; font-weight: 900; margin: 0; }
.subtitle { color: var(--text-secondary); margin-top: 6px; }
/* TABS */
.tabs-control { display: flex; gap: 12px; margin-bottom: 40px; padding-bottom: 20px; border-bottom: 1px solid var(--border-color); }
.tab-btn { background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-secondary); padding: 12px 24px; border-radius: 16px; display: flex; align-items: center; gap: 10px; cursor: pointer; transition: all 0.3s; font-weight: 700; }
.tab-btn.active { background: var(--active-color); color: #101820; border-color: var(--active-color); }
.tab-btn:hover:not(.active) { border-color: var(--active-color); color: var(--active-color); }
/* CONTENT */
.dashboard-layout { display: grid; grid-template-columns: 1fr 340px; gap: 40px; }
.analysis-section { margin-bottom: 60px; }
.section-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.section-header h2 { font-size: 1rem; font-weight: 800; text-transform: uppercase; color: var(--text-secondary); }
.side-info { display: flex; flex-direction: column; gap: 16px; }
.info-box { background: var(--bg-secondary); padding: 24px; border-radius: 24px; border: 1px solid var(--border-color); }
.info-box .material-icons { color: #fee715; margin-bottom: 12px; }
.info-box h4 { margin: 0 0 8px; font-weight: 800; color: #fee715; }
.info-box p { font-size: 0.85rem; line-height: 1.6; color: var(--text-secondary); margin: 0; }
/* KPI */
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 24px; margin-bottom: 40px; }
.kpi-card { background: var(--card-bg); padding: 24px; border-radius: 24px; border: 1px solid var(--border-color); display: flex; align-items: center; gap: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); }
.kpi-icon { width: 50px; height: 50px; border-radius: 14px; display: flex; align-items: center; justify-content: center; background: rgba(254, 231, 21, 0.1); color: #fee715; }
.kpi-value { display: block; font-size: 2rem; font-weight: 900; }
.kpi-label { font-size: 0.75rem; color: var(--text-secondary); font-weight: 600; text-transform: uppercase; }
/* TABLES & CHARTS */
.chart-container { height: 320px; background: rgba(0,0,0,0.2); border-radius: 24px; padding: 24px; border: 1px solid var(--border-color); }
.chart-container.large { height: 400px; }
.data-table-wrapper { background: var(--card-bg); border-radius: 24px; border: 1px solid var(--border-color); overflow: hidden; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th { padding: 16px; font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; text-align: left; background: rgba(255,255,255,0.02); }
.data-table td { padding: 18px 16px; border-bottom: 1px solid var(--border-color); }
.id-cell { font-family: monospace; color: #fee715; font-weight: 700; }
.progress-bar { width: 100%; height: 6px; background: rgba(255,255,255,0.05); border-radius: 10px; overflow: hidden; }
.progress-fill { height: 100%; background: #fee715; }
.mini-bar { width: 80px; height: 5px; background: rgba(255,255,255,0.05); border-radius: 10px; overflow: hidden; }
.mini-bar .fill { height: 100%; background: #fee715; }
.status-pill { padding: 4px 12px; border-radius: 100px; font-size: 0.7rem; font-weight: 800; }
.status-pill.excellent { background: rgba(16, 185, 129, 0.1); color: #10b981; }
.status-pill.good { background: rgba(59, 130, 246, 0.1); color: #3b82f6; }
.status-pill.low { background: rgba(244, 63, 94, 0.1); color: #f43f5e; }
/* ANIMATIONS */
.animate-fade { animation: fadeIn 0.4s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.spin { animation: spin 2s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@media (max-width: 1100px) { .dashboard-layout { grid-template-columns: 1fr; } .side-info { order: 2; } }
</style>

View File

@ -0,0 +1,959 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTaxiStore } from '@/stores/taxi'
import { useShuttleStore } from '@/stores/shuttle'
import { analyticsService } from '@/services/analyticsService'
import { API_URL } from '@/services/apiClient'
import type { Taxi, Shuttle } from '@/types'
import FavoriteButton from '@/components/FavoriteButton.vue'
const { t } = useI18n()
const taxiStore = useTaxiStore()
const shuttleStore = useShuttleStore()
const currentTab = ref<'local' | 'intercity'>('local')
const selectedZone = ref('all')
const selectedShift = ref('all')
const onlyEnglish = ref(false)
const corregimientos = ['all', 'Boquete', 'David - Boquete', 'Boquete - David', 'Aeropuerto - Boquete']
const shifts = ['all', 'dia', 'tarde', 'noche']
// Shuttle Filters
const shuttleRouteFilter = ref('all')
const shuttleTypeFilter = ref('all')
const expandedShuttleId = ref<string | null>(null)
const shuttleRoutes = computed(() => {
const routes = shuttleStore.shuttles.map(s => `${s.origin} - ${s.destination}`)
return [...new Set(routes)].sort()
})
const filteredShuttles = computed(() => {
return shuttleStore.shuttles.filter(shuttle => {
const routeName = `${shuttle.origin} - ${shuttle.destination}`
const matchesRoute = shuttleRouteFilter.value === 'all' || routeName === shuttleRouteFilter.value
const matchesType = shuttleTypeFilter.value === 'all' || shuttle.trip_type === shuttleTypeFilter.value
return matchesRoute && matchesType
})
})
onMounted(async () => {
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'TransportHub' })
await Promise.all([
taxiStore.loadTaxis(),
shuttleStore.loadShuttles()
])
})
const filteredTaxis = computed(() => {
return taxiStore.taxis.filter(taxi => {
const matchesZone = selectedZone.value === 'all' || taxi.corregimiento === selectedZone.value
const matchesShift = selectedShift.value === 'all' || taxi.shift === selectedShift.value
const matchesEnglish = !onlyEnglish.value || taxi.english_speaking
return matchesZone && matchesShift && matchesEnglish
})
})
function getImageUrl(path?: string) {
if (!path) return `https://ui-avatars.com/api/?name=Taxi&background=fee715&color=101820`
if (path.startsWith('http')) return path
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
}
const handleCall = (taxi: Taxi) => {
analyticsService.logEvent({
event_name: 'taxi_click',
item_id: taxi.owner_name,
properties: {
action: 'call',
taxi_id: taxi.id,
plate: taxi.license_plate
}
})
window.location.href = `tel:${taxi.phone_number}`
}
const handleReserve = (shuttle: Shuttle) => {
analyticsService.logEvent({
event_name: 'shuttle_contact',
item_id: shuttle.id,
properties: { action: 'whatsapp', route: shuttle.route_name }
})
const message = encodeURIComponent(`Hola SIBU, me gustaría reservar un cupo para la ruta: ${shuttle.route_name}.`)
window.open(`https://wa.me/${shuttle.contact_whatsapp.replace(/\+/g, '')}?text=${message}`, '_blank')
}
const handleCallShuttle = (shuttle: Shuttle) => {
analyticsService.logEvent({
event_name: 'shuttle_contact',
item_id: shuttle.id,
properties: { action: 'call', route: shuttle.route_name }
})
}
function getShiftLabel(shift: string) {
if (shift === 'dia') return t('taxi.dayShift')
if (shift === 'tarde') return t('taxi.afternoonShift')
if (shift === 'noche') return t('taxi.nightShift')
return shift
}
</script>
<template>
<div class="taxi-view">
<header class="header-main">
<h1 class="brand-title">{{ t('taxi.title') }}</h1>
<!-- Tab Switcher Premium -->
<div class="hub-tabs">
<div class="tabs-background">
<button
class="hub-tab"
:class="{ active: currentTab === 'local' }"
@click="currentTab = 'local'"
>
<span class="material-icons">local_taxi</span>
{{ t('taxi.tabLocal') }}
</button>
<button
class="hub-tab"
:class="{ active: currentTab === 'intercity' }"
@click="currentTab = 'intercity'"
>
<span class="material-icons">directions_bus</span>
{{ t('taxi.tabIntercity') }}
</button>
<div class="tab-slider" :style="{ left: currentTab === 'local' ? '4px' : 'calc(50% + 2px)' }"></div>
</div>
</div>
</header>
<!-- TAB 1: LOCAL TAXIS -->
<template v-if="currentTab === 'local'">
<div class="filters-container">
<div class="filter-card">
<div class="selectors-side">
<div class="select-group">
<span class="material-icons">location_on</span>
<select v-model="selectedZone">
<option value="all">{{ t('taxi.allZones') }}</option>
<option v-for="zone in corregimientos.filter(z => z !== 'all')" :key="zone" :value="zone">{{ zone }}</option>
</select>
</div>
<div class="select-group">
<span class="material-icons">schedule</span>
<select v-model="selectedShift">
<option value="all">{{ t('taxi.shift') }}</option>
<option v-for="s in shifts.filter(x => x !== 'all')" :key="s" :value="s">{{ getShiftLabel(s) }}</option>
</select>
</div>
</div>
<div class="lang-toggle-side">
<span class="lang-text">{{ t('taxi.englishLabel') }}</span>
<label class="checkbox-container">
<input type="checkbox" v-model="onlyEnglish">
<span class="checkmark"></span>
</label>
</div>
</div>
</div>
<div v-if="taxiStore.isLoading" class="state-container">
<span class="material-icons spin">refresh</span>
<p>{{ t('taxi.loadingTaxis') }}</p>
</div>
<div v-else-if="taxiStore.error" class="state-container">
<span class="material-icons">error_outline</span>
<p>{{ taxiStore.error }}</p>
</div>
<div v-else class="taxis-grid">
<div v-for="taxi in filteredTaxis" :key="taxi.id" class="taxi-card-new">
<div class="card-top">
<div class="driver-avatar">
<img :src="getImageUrl(taxi.image_url)" alt="Driver">
</div>
<div class="driver-info">
<h3>{{ taxi.owner_name }}</h3>
<div class="rating-stars">
<span v-for="i in 5" :key="i" class="material-icons">
{{ i <= (taxi.rating || 5) ? 'star' : 'star_border' }}
</span>
</div>
</div>
<div class="fav-icon-wrapper">
<FavoriteButton
item-type="taxi"
:item-id="taxi.id"
:item-name="taxi.owner_name"
:item-image="taxi.image_url || undefined"
/>
</div>
</div>
<div class="card-mid">
<div class="contact-info">
<span class="material-icons ph-icon">phone</span>
<span class="phone-num"> {{ taxi.phone_number }} </span>
</div>
</div>
<div class="card-bottom">
<button class="call-btn-main" @click="handleCall(taxi)">
<span class="material-icons">phone_in_talk</span>
{{ t('taxi.callNow') }}
</button>
</div>
</div>
<div v-if="filteredTaxis.length === 0" class="empty-state">
<span class="material-icons">no_accounts</span>
<p>{{ t('taxi.noTaxisAvailable') }}</p>
</div>
</div>
</template>
<!-- TAB 2: INTERCITY SHUTTLES -->
<template v-else>
<div class="filters-container">
<div class="filter-card">
<div class="selectors-side">
<div class="select-group">
<span class="material-icons">route</span>
<select v-model="shuttleRouteFilter">
<option value="all">{{ t('shuttle.allRoutes') }}</option>
<option v-for="route in shuttleRoutes" :key="route" :value="route">{{ route }}</option>
</select>
</div>
<div class="select-group">
<span class="material-icons">sync_alt</span>
<select v-model="shuttleTypeFilter">
<option value="all">{{ t('shuttle.tripType') }}</option>
<option value="one_way">{{ t('shuttle.oneWay') }}</option>
<option value="round_trip">{{ t('shuttle.roundTrip') }}</option>
<option value="both">{{ t('shuttle.both') }}</option>
</select>
</div>
</div>
</div>
</div>
<div v-if="shuttleStore.isLoading" class="state-container">
<span class="material-icons spin">refresh</span>
<p>{{ t('taxi.loadingTaxis') }}</p>
</div>
<div v-else-if="shuttleStore.error" class="state-container">
<span class="material-icons">error_outline</span>
<p>{{ shuttleStore.error }}</p>
</div>
<div v-else class="shuttles-grid">
<div
v-for="shuttle in filteredShuttles"
:key="shuttle.id"
class="shuttle-card"
:class="{ expanded: expandedShuttleId === shuttle.id }"
:style="{ backgroundImage: `url(${shuttle.image_url || 'https://images.unsplash.com/photo-1449034446853-66c86144b0ad?auto=format&fit=crop&q=80&w=2070'})` }"
@click="() => {
expandedShuttleId = expandedShuttleId === shuttle.id ? null : shuttle.id;
if (expandedShuttleId === shuttle.id) {
analyticsService.logEvent({ event_name: 'shuttle_view', item_id: shuttle.id });
}
}"
>
<div class="shuttle-main-info">
<div class="shuttle-header-mini">
<div class="company-badge" v-if="shuttle.company_name">
<span class="material-icons">business</span>
{{ shuttle.company_name }}
</div>
<div class="price-pill">
<span class="currency">$</span>
<span class="amount">{{ shuttle.price_per_person }}</span>
</div>
</div>
<div class="shuttle-route-compact" v-if="shuttle.origin && shuttle.destination">
<span class="route-text">{{ shuttle.origin }}</span>
<span class="material-icons">east</span>
<span class="route-text">{{ shuttle.destination }}</span>
</div>
<div class="shuttle-tags">
<div class="vehicle-tag-mini">
<span class="material-icons">directions_bus</span>
{{ shuttle.vehicle_type }}
</div>
<div class="expand-indicator">
<span class="material-icons">{{ expandedShuttleId === shuttle.id ? 'expand_less' : 'expand_more' }}</span>
</div>
</div>
</div>
<!-- EXPANDABLE CONTENT -->
<div class="shuttle-details" v-if="expandedShuttleId === shuttle.id">
<div class="shuttle-body">
<div class="info-row">
<span class="material-icons">schedule</span>
<div>
<p class="label">{{ t('shuttle.duration') }}</p>
<p class="value">{{ shuttle.estimated_duration }}</p>
</div>
</div>
<div class="info-row">
<span class="material-icons">event</span>
<div>
<p class="label">{{ t('shuttle.departure') }}</p>
<p class="value">{{ shuttle.departure_times }}</p>
</div>
</div>
</div>
<div class="shuttle-footer">
<div class="price-box">
<div class="price-main">
<span class="currency">$</span>
<span class="amount">{{ shuttle.price_per_person }}</span>
<span class="suffix">{{ t('shuttle.perPerson') }}</span>
</div>
<div class="lang-badge" v-if="shuttle.english_speaking">
<span class="material-icons">g_translate</span>
BILINGUAL
</div>
</div>
<div class="contact-hub">
<a
v-if="shuttle.phone_number"
:href="'tel:' + shuttle.phone_number"
class="mini-btn phone"
@click.stop="handleCallShuttle(shuttle)"
>
<span class="material-icons">phone</span>
</a>
<button class="mini-btn wa" @click.stop="handleReserve(shuttle)">
<span class="material-icons">chat</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="filteredShuttles.length === 0" class="empty-state">
<span class="material-icons">directions_bus_filled</span>
<p>{{ t('shuttle.noShuttles') }}</p>
</div>
</template>
</div>
</template>
<style scoped>
/* Transport Hub Tabs */
.hub-tabs {
margin-top: 24px;
display: flex;
justify-content: center;
}
.tabs-background {
background: var(--bg-secondary);
padding: 4px;
border-radius: 16px;
display: flex;
position: relative;
border: 1px solid var(--border-color);
width: 100%;
max-width: 400px;
}
.hub-tab {
flex: 1;
padding: 12px;
border: none;
background: transparent;
color: var(--text-secondary);
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
z-index: 2;
transition: color 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.hub-tab.active {
color: #101820 !important;
}
.hub-tab .material-icons {
font-size: 18px;
}
.tab-slider {
position: absolute;
top: 4px;
bottom: 4px;
width: calc(50% - 6px);
background: #FEE715;
border-radius: 12px;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
box-shadow: 0 4px 15px rgba(254, 231, 21, 0.4);
}
/* Shuttles Grid & Compact Cards */
.shuttles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
padding: 20px 16px;
}
.shuttle-card.expanded {
border-color: var(--active-color);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.shuttle-main-info {
padding: 0; /* Ya manejado por el contenedor superior */
}
.shuttle-header-mini {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.company-badge {
display: flex;
align-items: center;
gap: 6px;
background: rgba(254, 231, 21, 0.1);
color: var(--active-color);
padding: 4px 10px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 800;
border: 1px solid rgba(254, 231, 21, 0.2);
}
.price-pill {
background: var(--active-color);
color: #101820;
padding: 4px 10px;
border-radius: 8px;
font-weight: 900;
font-size: 0.9rem;
display: flex;
align-items: baseline;
gap: 2px;
}
.shuttle-route-compact {
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 800;
color: var(--text-primary);
margin-bottom: 12px;
}
.shuttle-route-compact .material-icons {
color: var(--active-color);
font-size: 18px;
}
.shuttle-tags {
display: flex;
justify-content: space-between;
align-items: center;
}
.vehicle-tag-mini {
padding: 4px 10px;
background: var(--bg-secondary);
border-radius: 8px;
font-size: 0.7rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.expand-indicator {
color: var(--active-color);
display: flex;
align-items: center;
}
/* Expanded content */
.shuttle-details {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed var(--border-color);
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.shuttle-body {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.info-row {
display: flex;
align-items: center;
gap: 12px;
}
.info-row .material-icons {
color: var(--active-color);
font-size: 20px;
}
.info-row .label {
font-size: 0.7rem;
color: var(--text-secondary);
margin: 0;
text-transform: uppercase;
font-weight: 700;
}
.info-row .value {
font-size: 0.9rem;
color: var(--text-primary);
margin: 2px 0 0;
font-weight: 600;
}
.shuttle-footer {
display: flex;
justify-content: space-between;
align-items: flex-end; /* Alineados a la base */
margin-top: auto;
padding-top: 15px;
width: 100%;
}
.price-box {
display: flex;
flex-direction: column;
}
.price-main {
color: var(--active-color);
display: flex;
align-items: baseline;
gap: 2px;
}
.price-main .currency {
font-size: 0.9rem;
font-weight: 900;
}
.price-main .amount {
font-size: 1.5rem;
font-weight: 900;
}
.price-main .suffix {
font-size: 0.7rem;
color: var(--text-secondary);
margin-left: 4px;
}
.price-sub {
font-size: 0.7rem;
color: var(--text-secondary);
font-weight: 600;
}
.contact-hub {
display: flex;
gap: 10px;
margin-left: auto; /* Empuja al máximo a la derecha */
}
.mini-btn {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
transition: all 0.3s;
text-decoration: none;
}
.mini-btn.phone { background: rgba(255,255,255,0.1); color: white; border: 1px solid rgba(255,255,255,0.2); }
.mini-btn.wa { background: #25d366; color: white; box-shadow: 0 4px 12px rgba(37, 211, 102, 0.2); }
.mini-btn:hover { transform: translateY(-3px); }
.lang-badge {
display: flex;
align-items: center;
gap: 4px;
background: rgba(254, 231, 21, 0.2);
color: var(--active-color);
padding: 2px 8px;
border-radius: 6px;
font-size: 0.6rem;
font-weight: 800;
margin-top: 4px;
width: fit-content;
}
.lang-badge .material-icons { font-size: 10px; }
/* Original Styles */
.taxi-view {
min-height: 100vh;
position: relative;
padding: 0 0 150px; /* Aumentado para evitar solapamiento con BottomNav */
}
.taxi-view {
min-height: 100vh;
position: relative;
padding: 0 0 150px;
}
/* Tarjetas claras y elegantes */
.filter-card,
.taxi-card-new {
background: var(--card-bg);
border: 1px solid var(--border-color);
}
/* SHUTTLE CARD PREMIUM CON FONDO */
.shuttle-card {
border-radius: 20px;
overflow: hidden;
border: 1px solid var(--border-color);
background-size: cover;
background-position: center;
position: relative;
min-height: 160px;
display: flex;
flex-direction: column;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.shuttle-card::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.3) 100%);
z-index: 0;
}
.shuttle-main-info, .shuttle-details {
position: relative;
z-index: 1;
padding: 20px;
}
.header-main {
padding: 24px 16px;
text-align: center;
}
.brand-title {
color: var(--header-text);
font-size: 1.5rem;
font-weight: 800;
margin: 0;
}
.filters-container {
padding: 0 16px 24px;
}
.filter-card {
background: var(--card-bg);
border-radius: 20px;
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px var(--shadow);
}
.selectors-side {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.select-group {
background: var(--bg-secondary);
border: 1.5px solid #fee715;
border-radius: 12px;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 10px;
}
.select-group .material-icons {
color: #fee715;
font-size: 20px;
}
.select-group select {
background: transparent;
border: none;
color: var(--text-primary);
width: 100%;
font-size: 0.9rem;
outline: none;
}
.select-group select option {
background: var(--card-bg);
color: var(--text-primary);
}
.lang-toggle-side {
padding-left: 20px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.lang-text {
color: var(--text-primary);
font-weight: 700;
font-size: 0.9rem;
}
/* Custom Checkbox */
.checkbox-container {
display: block;
position: relative;
width: 28px;
height: 28px;
cursor: pointer;
}
.checkbox-container input {
visibility: hidden;
width: 0;
height: 0;
}
.checkmark {
position: absolute;
top: 0;
left: 0;
height: 28px;
width: 28px;
background-color: transparent;
border: 2px solid #fee715;
border-radius: 4px;
transition: all 0.2s ease;
}
.checkbox-container input:checked ~ .checkmark {
background-color: #fee715;
}
.checkmark:after {
content: "";
position: absolute;
display: none;
}
.checkbox-container input:checked ~ .checkmark:after {
display: block;
}
.checkbox-container .checkmark:after {
left: 9px;
top: 5px;
width: 6px;
height: 12px;
border: solid #101820;
border-width: 0 3px 3px 0;
transform: rotate(45deg);
}
/* Grid & Cards */
.taxis-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 24px;
padding: 24px 0;
}
.taxi-card-new {
background: var(--card-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 20px;
padding: 16px;
border: 1px solid var(--border-color);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
}
.taxi-card-new:hover {
transform: translateY(-8px);
border-color: var(--active-color);
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
}
.card-top {
display: flex;
align-items: center;
gap: 16px;
position: relative;
}
.driver-avatar {
width: 44px;
height: 44px;
border-radius: 12px;
overflow: hidden;
background: var(--bg-secondary);
border: 2px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
}
.driver-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.driver-info h3 {
margin: 0 0 2px;
color: var(--text-primary);
font-size: 1rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.rating-stars {
display: flex;
gap: 4px;
}
.rating-stars .material-icons {
color: var(--active-color);
font-size: 18px;
filter: drop-shadow(0 0 5px rgba(254, 231, 21, 0.5));
}
.fav-icon-wrapper {
position: absolute;
top: -4px;
right: -4px;
}
.card-mid {
padding: 12px 16px;
background: var(--bg-secondary);
border-radius: 16px;
border: 1px solid var(--border-color);
}
.contact-info {
display: flex;
align-items: center;
gap: 12px;
}
.ph-icon {
color: var(--active-color);
font-size: 20px;
}
.phone-num {
color: var(--text-primary);
font-size: 1rem;
font-weight: 700;
}
.call-btn-main {
width: 100%;
padding: 18px;
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
color: #101820;
border: none;
border-radius: 16px;
font-size: 1rem;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 20px rgba(254, 231, 21, 0.15);
}
.call-btn-main:hover {
transform: translateY(-4px);
box-shadow: 0 15px 30px rgba(254, 231, 21, 0.25);
}
.state-container, .empty-state {
padding: 100px 24px;
text-align: center;
background: var(--header-bg);
border-radius: 32px;
border: 2px dashed var(--border-color);
}
.state-container .material-icons, .empty-state .material-icons {
font-size: 64px;
margin-bottom: 24px;
color: var(--active-color);
opacity: 0.5;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 600px) {
.taxis-grid, .shuttles-grid {
grid-template-columns: 1fr;
}
}
</style>