diff --git a/frontend/src/App.vue b/frontend/src/App.vue index faca183..c57ee7d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -17,9 +17,7 @@ const favoritesStore = useFavoritesStore() const isSplashScreen = computed(() => route.name === 'splash') const isAuthScreen = computed(() => { - const name = route.name?.toString().toLowerCase() || '' - const path = route.path.toLowerCase() - return name.includes('auth') || path.includes('/login') || path.includes('/register') + return route.path === '/login' || route.path === '/register' || route.name === 'auth' }) onMounted(() => { diff --git a/frontend/src/components/map/ArrivalBanner.vue b/frontend/src/components/map/ArrivalBanner.vue new file mode 100644 index 0000000..1155b59 --- /dev/null +++ b/frontend/src/components/map/ArrivalBanner.vue @@ -0,0 +1,132 @@ + + + + + directions_bus + + + + {{ t('map.arrivalTime') }} + {{ stopName }} + + + + + + + + {{ etaValue }} + min + + + -- min + + + + + close + + + + + + + + diff --git a/frontend/src/components/map/PromoCarousel.vue b/frontend/src/components/map/PromoCarousel.vue new file mode 100644 index 0000000..f40bd2a --- /dev/null +++ b/frontend/src/components/map/PromoCarousel.vue @@ -0,0 +1,268 @@ + + + + + + + {{ t('coupons.title') }} + + + close + + + + + + + chevron_left + + + + + + + {{ currentPromo.business?.name || 'Local' }} + {{ currentPromo.title }} + + {{ t('coupons.viewDetails') }} + -{{ currentPromo.discount_percentage }}% + + + + + + + + chevron_right + + + + + + + + + + + + + + diff --git a/frontend/src/components/map/SearchOverlay.vue b/frontend/src/components/map/SearchOverlay.vue new file mode 100644 index 0000000..733f797 --- /dev/null +++ b/frontend/src/components/map/SearchOverlay.vue @@ -0,0 +1,233 @@ + + + + + + + search + + + + + directions_bus + {{ t('map.viewRoutes') }} + + + + + + + + + + + + arrow_back + + {{ t('map.availableRoutes') }} + + + + + + + directions_bus + + + {{ route.name }} + {{ t('map.busRoute') }} + + + {{ route.id === selectedRouteId ? 'check_circle' : 'chevron_right' }} + + + + + + + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index f25069d..1f0ac59 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -177,8 +177,14 @@ router.beforeEach(async (to, _from, next) => { return next() } - // Verificar sesión activa - const { data: { session } } = await supabase.auth.getSession() + // Verificar sesión activa con seguridad + let session = null + try { + const { data } = await supabase.auth.getSession() + session = data.session + } catch (e) { + console.error('SIBU | Auth Check Error:', e) + } // Sin sesión en ruta protegida → login if (!session) { diff --git a/frontend/src/stores/busStop.ts b/frontend/src/stores/busStop.ts index 98ed5a3..ee43758 100644 --- a/frontend/src/stores/busStop.ts +++ b/frontend/src/stores/busStop.ts @@ -15,6 +15,7 @@ export const useBusStopStore = defineStore('busStop', () => { const CACHE_TIME = 1000 * 60 * 30; // 30 minutos const now = Date.now(); + if (isLoading.value) return; if (!force && busStops.value.length > 0 && (now - lastFetched.value < CACHE_TIME)) { return } @@ -33,6 +34,7 @@ export const useBusStopStore = defineStore('busStop', () => { } async function loadBusStopById(id: string, force = false) { + if (isLoading.value) return; // Buscar en cache primero if (!force && busStops.value.length > 0) { const cachedStop = busStops.value.find(s => s.id === id); diff --git a/frontend/src/stores/coupon.ts b/frontend/src/stores/coupon.ts index 2a6e9d4..8b1d770 100644 --- a/frontend/src/stores/coupon.ts +++ b/frontend/src/stores/coupon.ts @@ -12,6 +12,7 @@ export const useCouponStore = defineStore('coupon', () => { const filters = ref({}) async function loadCoupons(newFilters?: CouponFilters) { + if (isLoading.value) return; isLoading.value = true error.value = null if (newFilters) { diff --git a/frontend/src/stores/route.ts b/frontend/src/stores/route.ts index 463bc10..9a840bf 100644 --- a/frontend/src/stores/route.ts +++ b/frontend/src/stores/route.ts @@ -22,6 +22,9 @@ export const useRouteStore = defineStore('route', () => { const CACHE_TIME = 1000 * 60 * 15; // 15 minutos const now = Date.now(); + // Guard: Si ya se están cargando rutas, no iniciar otra petición + if (isLoadingRoutes.value) return; + // Si no forzamos, no hay filtros raros, ya tenemos rutas y aún no expira el caché, omitir llamada if (!force && !filters && allRoutes.value.length > 0 && (now - lastFetched.value < CACHE_TIME)) { return @@ -44,6 +47,7 @@ export const useRouteStore = defineStore('route', () => { const CACHE_TIME = 1000 * 60 * 15; // 15 minutos const now = Date.now(); + if (isLoadingStops.value) return; if (!force && stopsCache.value.has(routeId)) { const cacheEntry = stopsCache.value.get(routeId)!; if (now - cacheEntry.fetchedAt < CACHE_TIME) { diff --git a/frontend/src/supabase.ts b/frontend/src/supabase.ts index 9c81344..d5c94d9 100644 --- a/frontend/src/supabase.ts +++ b/frontend/src/supabase.ts @@ -1,7 +1,7 @@ import { createClient } from '@supabase/supabase-js' -export const SUPABASE_URL = 'https://bjgixlugjzsccazdfmph.supabase.co' -export const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJqZ2l4bHVnanpzY2NhemRmbXBoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIwNjQyMTAsImV4cCI6MjA4NzY0MDIxMH0.untLQoPi4yUr3cPnxo23wYSlg6xnNK0daKu9UHmFTp8' +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL +const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY // SIBU | Hybrid Storage: Maneja persistencia según la voluntad del usuario const authStorage = { diff --git a/frontend/src/utils/geo.ts b/frontend/src/utils/geo.ts new file mode 100644 index 0000000..8041913 --- /dev/null +++ b/frontend/src/utils/geo.ts @@ -0,0 +1,39 @@ +/** + * Utility functions for geographical calculations + */ + +/** + * Calculates the Haversine distance between two points + */ +export function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371e3; // Earth radius in meters + const f1 = lat1 * Math.PI / 180; + const f2 = lat2 * Math.PI / 180; + const df = (lat2 - lat1) * Math.PI / 180; + const dl = (lon2 - lon1) * Math.PI / 180; + + const a = Math.sin(df / 2) * Math.sin(df / 2) + + Math.cos(f1) * Math.cos(f2) * + Math.sin(dl / 2) * Math.sin(dl / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; // Distance in meters +} + +/** + * Formats distance in meters to a human-readable string + */ +export function formatDistance(meters: number): string { + if (meters < 1000) { + return `${Math.round(meters)}m`; + } + return `${(meters / 1000).toFixed(1)}km`; +} + +/** + * Estimates walking time in minutes + */ +export function estimateWalkingTime(meters: number): number { + const walkingSpeed = 1.4; // 1.4 m/s approx + return Math.ceil(meters / walkingSpeed / 60); +} diff --git a/frontend/src/views/AdminDashboard.vue b/frontend/src/views/AdminDashboard.vue deleted file mode 100644 index 0896d08..0000000 --- a/frontend/src/views/AdminDashboard.vue +++ /dev/null @@ -1,521 +0,0 @@ - - - - - - arrow_back - - - Análisis Estratégico - Métricas en tiempo real del ecosistema SIBU - - - - - Actualizado: {{ lastRefreshed }} - - - refresh - {{ isLoading ? 'Sincronizando...' : 'Refrescar' }} - - - - - - - - Procesando datos... - - - - - - - - analytics - - Eventos Totales - {{ stats.total_events?.toLocaleString() || '0' }} - - - - speed - - Pico de Actividad - {{ stats.peak_hours?.[0]?.hour || '--' }}:00 - - - - visibility - - Pantalla Principal - {{ stats.screen_activity?.[0]?.name || 'Mapa' }} - - - - - - - - - Tendencia de Uso (Últimos 7 días) - - - - - - - - - Distribución por Idioma - - - - - - - - - - - Popularidad de Rutas - - - - - - - - - Visualizaciones de Promociones - - - - - - - - - - - Top Acciones (Taxis/Otros) - - - - {{ Number(idx) + 1 }} - {{ taxi.id || 'N/A' }} - {{ taxi.count }} - - Sin registros - - - - - - Top Paradas - - - - {{ Number(idx) + 1 }} - {{ stop.id || 'N/A' }} - {{ stop.count }} - - Sin registros - - - - - - - - Actividad por Hora - - - - - - - - - - - - - diff --git a/frontend/src/views/DiscoverView.vue b/frontend/src/views/DiscoverView.vue index 0fc0986..900b75c 100644 --- a/frontend/src/views/DiscoverView.vue +++ b/frontend/src/views/DiscoverView.vue @@ -562,7 +562,7 @@ function resetFilters() { border-radius: 1.125rem; overflow: hidden; cursor: pointer; - aspect-ratio: 1/1; + aspect-ratio: 16/9; background: var(--bg-secondary); transition: transform 0.2s; } @@ -660,7 +660,7 @@ function resetFilters() { .biz-img-wrap { position: relative; - aspect-ratio: 16/10; + aspect-ratio: 4/3; overflow: hidden; } @@ -710,9 +710,9 @@ function resetFilters() { align-items: center; gap: 0.2rem; margin: 0; - font-size: 0.75rem; + font-size: 0.7rem; color: var(--text-secondary); - font-weight: 600; + font-weight: 500; } .biz-area-icon { font-size: 0.875rem; } diff --git a/frontend/src/views/MapView.vue b/frontend/src/views/MapView.vue index 5f5d6b4..f882bba 100644 --- a/frontend/src/views/MapView.vue +++ b/frontend/src/views/MapView.vue @@ -1,10 +1,9 @@ - - - - - {{ t('map.calculatingRoute') }} + + {{ t('map.calculatingRoute') }} + {{ errorRuta }} - - {{ errorRuta }} - - - - - - - + - - ⚠️ {{ t('map.mapLoadingError') }} - - {{ mapsError }} - + + ⚠️ {{ t('map.mapLoadingError') }} + {{ mapsError }} {{ t('map.loadingMap') }} - - - - + local_offer - - {{ couponStore.coupons.length }} - + {{ couponStore.coupons.length }} - - + my_location - - - - - - - - search - - - - - directions_bus - {{ t('map.viewRoutes') }} - - - - - - - directions_bus - - - - {{ t('map.arrivalTime') }} - {{ paradaCercana?.name }} - - - - - - - - {{ (busesActivos[0]?.etaMinutos ?? 0) > 0 ? busesActivos[0]?.etaMinutos : '0' }} - min - - - -- min - - - - - close - - - - - - - - - - - - arrow_back - - {{ t('map.availableRoutes') }} - - - - - - - - - - - - - directions_bus - - - {{ route.name }} - {{ t('map.busRoute') }} - - - {{ route.id === routeStore.selectedRouteId ? 'check_circle' : 'chevron_right' }} - - - - - - - - - - - - - - - - {{ t('coupons.title') }} - - - close - - - - - - - chevron_left - - - - - - - {{ currentPromo.business?.name || 'Local' }} - {{ currentPromo.title }} - - {{ t('coupons.viewDetails') }} - -{{ currentPromo.discount_percentage }}% - - - - - - - - chevron_right - - - - - - - - - - - + + + + + + + + + + + - + {{ t('map.promo') }} @@ -883,7 +427,9 @@ async function highlightOptimalStopForRoute() { {{ selectedPromo.description }} - {{ t('business.viewBusiness') }} + + {{ t('business.viewBusiness') }} + @@ -906,12 +452,17 @@ async function highlightOptimalStopForRoute() { .split-view { display: flex; width: 100%; - height: calc(100vh - 64px); /* Adjust based on header height */ + height: calc(100vh - 64px); overflow: hidden; position: relative; } -/* SIBU Directions API status tags */ +.map-side, .map-view, .map-container, .map { + width: 100%; + height: 100%; + position: relative; +} + .status-indicator { position: absolute; top: 1rem; @@ -925,63 +476,29 @@ async function highlightOptimalStopForRoute() { } .loading-pill { - background-color: #1e40af; /* Tailwind blue-800 */ + background: #1e40af; color: white; padding: 0.5rem 1rem; border-radius: 9999px; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - border: 2px solid white; font-size: 0.875rem; - font-weight: 500; - animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + animation: pulse 2s infinite; } -.error-pill { - background-color: #dc2626; /* Tailwind red-600 */ - color: white; - padding: 0.5rem 1rem; - border-radius: 0.375rem; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - font-size: 0.875rem; +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } } + +.error-content { text-align: center; padding: 20px; } +.error-detail { color: var(--text-primary); background: var(--bg-secondary); padding: 15px; border-radius: 8px; margin-top: 10px; } + +.map-floating-controls { + position: fixed; + bottom: 85px; + right: 16px; + display: flex; + flex-direction: column; + gap: 16px; + z-index: 1001; } -@keyframes pulse { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: .5; - } -} - - -.map-side { - width: 100%; - height: 100%; - position: relative; -} - -.map-view { - width: 100%; - height: 100%; - position: relative; -} - -.map-container { - width: 100%; - height: 100%; -} - -.map { - width: 100%; - height: 100%; -} - -/* ═══════════════════════════════════════ - BOTÓN DE OFERTAS (MAPA) - Mantenido simple y funcional - No premiun - solo funcional -═══════════════════════════════════════ */ .offers-fab { width: 56px; height: 56px; @@ -989,1003 +506,65 @@ async function highlightOptimalStopForRoute() { background: #fee715; color: #000; border: none; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; box-shadow: 0 4px 10px rgba(0,0,0,0.3); position: relative; - z-index: 1001; } .offers-badge { position: absolute; - top: -5px; - right: -5px; + top: -5px; right: -5px; background: #f44336; color: white; - font-size: 12px; - font-weight: bold; padding: 2px 6px; border-radius: 10px; border: 2px solid #fff; } -/* ═══════════════════════════════════════ - OFFERS BOTTOM SHEET -═══════════════════════════════════════ */ -.offers-sheet { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: calc(100% - 32px); - max-width: 420px; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px) saturate(180%); - -webkit-backdrop-filter: blur(20px) saturate(180%); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 24px; - z-index: 3000; /* Aumentado para estar sobre todo */ - padding: 12px 0 0; /* Padding superior para el título, 0 abajo para que la imagen pegue */ - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); - color: #000; - overflow: hidden; - transition: all 0.6s cubic-bezier(0.32, 0.72, 0, 1); -} - -@media (prefers-color-scheme: dark) { - .offers-sheet { - background: rgba(20, 20, 20, 0.8); - border-color: rgba(255, 255, 255, 0.1); - } - .sheet-title { - color: #FFFFFF !important; - } -} - -.sheet-header { - position: relative; - display: flex; - align-items: center; - justify-content: center; - padding: 0 16px 12px; - margin-bottom: 0px; -} - -.sheet-header-left { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.sheet-star { color: var(--active-color); font-size: 1.125rem; } - -.sheet-title { - font-size: 1.5rem; - font-weight: 900; - color: #101820; - text-align: center; - letter-spacing: -0.02em; -} - -.sheet-count-badge { - background: var(--active-color); - color: #101820; - font-size: 0.6875rem; - font-weight: 800; - padding: 0.15rem 0.5rem; - border-radius: 99px; -} - -.sheet-close { - position: absolute; - right: 16px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 50%; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: var(--text-secondary); - transition: color 0.2s; -} -.sheet-close:hover { color: var(--text-primary); } -.sheet-close .material-icons { font-size: 1.125rem; } - -.sheet-card-area { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - padding: 0; - min-height: 200px; -} - -.sheet-nav { - position: absolute; - top: 50%; - transform: translateY(-50%); - z-index: 10; - width: 36px; - height: 36px; - border-radius: 50%; - border: none; - background: rgba(255, 255, 255, 0.4); - backdrop-filter: blur(8px); - color: #101820; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s; -} - -.sheet-nav:first-of-type { left: 12px; } -.sheet-nav:last-of-type { right: 12px; } -.sheet-nav:disabled { opacity: 0.1; cursor: default; } -.sheet-nav:not(:disabled):hover { - background: var(--active-color); - color: #101820; - transform: scale(1.1); -} -.sheet-nav .material-icons { font-size: 1.125rem; } - -@media (prefers-color-scheme: dark) { - .sheet-nav { - background: rgba(255, 255, 255, 0.1); - } -} - -.sheet-card { - width: 100%; - height: 200px; - margin: 0; - border-radius: 0; - background-size: cover; - background-position: center; - position: relative; - overflow: hidden; - display: flex; -} - -.sheet-card-overlay { - width: 100%; - height: 100%; - background: linear-gradient( - to bottom, - rgba(0,0,0,0.5) 0%, - rgba(0,0,0,0) 30%, - rgba(0,0,0,0) 60%, - rgba(0,0,0,0.85) 100% - ); - display: flex; - flex-direction: column; - justify-content: flex-end; - padding: 1.25rem; -} - -.sheet-info { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.sheet-biz-name { - font-size: 0.75rem; - font-weight: 800; - color: #fee715; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.sheet-promo-title { - margin: 0; - font-size: 1.1rem; - font-weight: 900; - color: #fff; - line-height: 1.2; - margin-bottom: 0.5rem; -} - -.sheet-actions { - display: flex; - align-items: center; - justify-content: space-between; -} - -.sheet-cta { - background: var(--active-color); - color: #101820; - border: none; - padding: 8px 20px; - border-radius: 100px; - font-size: 0.8125rem; - font-weight: 800; - cursor: pointer; - box-shadow: 0 4px 15px rgba(254, 231, 21, 0.3); - transition: all 0.2s; -} - -.sheet-cta:active { transform: scale(0.95); } - -.sheet-discount-tag { - background: #f43f5e; - color: #fff; - font-size: 0.75rem; - font-weight: 900; - padding: 0.25rem 0.6rem; - border-radius: 8px; -} - -/* Dots */ -.sheet-dots { - display: flex; - justify-content: center; - gap: 6px; - padding: 0.25rem 0 0.25rem; -} - -.sheet-dot { - width: 8px; - height: 8px; - border-radius: 50%; - border: none; - background: var(--border-color); - cursor: pointer; - transition: all 0.25s; - padding: 0; -} - -.sheet-dot--active { - width: 20px; - border-radius: 4px; - background: var(--active-color); -} - -/* Carousel Slide Animation - Fluid */ -.carousel-slide-enter-active, -.carousel-slide-leave-active { - transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1); -} -.carousel-slide-enter-from { opacity: 0; transform: translateX(40px) scale(0.95); } -.carousel-slide-leave-to { opacity: 0; transform: translateX(-40px) scale(0.95); } - -/* Uber-like Search Interface Styles */ -.uber-search-container { - position: fixed; - top: 90px; - left: 16px; - right: 16px; - z-index: 1100; - pointer-events: none; -} - -.uber-search-container > * { - pointer-events: auto; /* Re-enable for children */ -} - -.uber-search-trigger { - background: var(--header-bg); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - height: 44px; /* Tamaño compacto ajustado */ - border-radius: 12px; - display: flex; - align-items: center; - padding: 0 16px; - box-shadow: var(--shadow); - cursor: pointer; - border: 1px solid var(--border-color); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - width: 100%; - max-width: 500px; -} - -.uber-search-trigger-compact { - background: var(--active-color) !important; - color: #101820 !important; /* Texto oscuro para el amarillo SIBU */ - height: 44px; /* Tamaño del logo / botones header */ - border-radius: 12px; - display: flex; - align-items: center; - gap: 8px; - padding: 0 16px; - box-shadow: 0 4px 12px rgba(0,0,0,0.2); - cursor: pointer; - border: none; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - width: fit-content; - pointer-events: auto; -} - -/* En modo claro, el botón es azul, usamos texto blanco */ -html.light-theme .uber-search-trigger-compact { - color: #ffffff !important; -} - -.uber-search-trigger-compact:hover { - transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(0,0,0,0.3); -} - -.uber-search-trigger-compact:active { - transform: scale(0.94); - filter: brightness(0.9); -} - -.uber-search-trigger-compact .search-icon { - margin: 0; - font-size: 20px; - color: inherit !important; -} - -.trigger-label { - font-size: 0.9rem; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.02em; - white-space: nowrap; -} - -.uber-search-trigger.circular { - width: 44px; /* Mantener cuadrado */ - padding: 0; - justify-content: center; - border-radius: 12px; -} - -.triggers-row { - display: flex; - gap: 12px; - align-items: center; -} - -.schedules-btn-floating { - background: linear-gradient(135deg, #fee715 0%, #facc15 100%); - color: #101820; - border: none; - height: 60px; - padding: 0 24px; - border-radius: 20px; - display: flex; - align-items: center; - gap: 10px; - font-weight: 800; - box-shadow: 0 10px 20px rgba(254, 231, 21, 0.2); - cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - white-space: nowrap; -} - -.schedules-btn-floating:hover { - transform: translateY(-4px); - box-shadow: 0 15px 30px rgba(254, 231, 21, 0.3); -} - -.schedules-btn-floating:active { - transform: scale(0.92); -} - -.uber-search-trigger:hover { - transform: translateY(-4px); - background: var(--hover-bg); -} - -.uber-search-trigger:active { - transform: scale(0.96); -} - -.search-icon { - color: var(--active-color); - margin-right: 12px; -} - -.trigger-text { - color: var(--text-primary); - font-size: 1.1rem; - font-weight: 700; - letter-spacing: -0.01em; -} - -.best-stop-banner { - flex: 1; /* Ocupa el espacio restante al lado de la búsqueda circular */ - background: var(--header-bg); - border: 1px solid var(--border-color); - box-shadow: 0 8px 32px rgba(0,0,0,0.3); - max-width: none; -} - -.best-stop-banner-compact { - flex: 1; - background: var(--header-bg); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - height: 40px; - border-radius: 10px; - display: flex; - align-items: center; - padding: 0 10px; - box-shadow: 0 4px 12px rgba(0,0,0,0.15); - border: 1px solid var(--border-color); - max-width: 100%; - overflow: hidden; - pointer-events: auto; - z-index: 1200; - min-width: 0; -} - -/* Animaciones del Banner (Slide de arriba hacia abajo, muy fluido) */ -.banner-slide-enter-active { - transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1); -} -.banner-slide-leave-active { - transition: all 0.4s cubic-bezier(0.7, 0, 0.84, 0); -} - -.banner-slide-enter-from, -.banner-slide-leave-to { - transform: translateY(-100%) scale(0.9); - opacity: 0; -} - -.banner-slide-enter-to, -.banner-slide-leave-from { - transform: translateY(0) scale(1); - opacity: 1; -} - -.banner-icon-bg { - background: #EAB308; /* yellow-500 */ - width: 24px; - height: 24px; - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.trigger-text-compact { - color: var(--text-primary); - font-size: 0.95rem; - font-weight: 700; - letter-spacing: -0.01em; -} - -.eta-badge { - background: rgba(234, 179, 8, 0.1); /* yellow-500 with opacity */ - color: #EAB308; - padding: 2px 8px; - border-radius: 6px; - display: flex; - align-items: baseline; - gap: 2px; - font-weight: 800; - margin-left: 8px; - border: 1px solid rgba(234, 179, 8, 0.2); -} - -.eta-value { - font-size: 1.1rem; - line-height: 1; -} - -.eta-unit { - font-size: 0.7rem; - text-transform: uppercase; -} - -.eta-loader { - width: 14px; - height: 14px; - border: 2px solid #EAB308; - border-top-color: transparent; - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -.uber-search-panel { - position: fixed; - top: 70px; /* Debajo del header superior */ - left: 0; - right: 0; - background: var(--header-bg); - backdrop-filter: blur(30px); - -webkit-backdrop-filter: blur(30px); - border-radius: 24px; - box-shadow: 0 40px 100px rgba(0,0,0,0.6); - padding: 16px; - z-index: 2500; - border: 1px solid var(--border-color); - overflow-y: auto; - transform-origin: top center; -} - -/* Fix para que no se oculte al salir el teclado */ -.uber-search-panel.is-focused { - top: 60px; - box-shadow: 0 10px 40px rgba(0,0,0,0.4); -} - -.uber-search-header { - display: flex; - align-items: center; - margin-bottom: 12px; -} - -.back-btn { - background: var(--hover-bg); - border: none; - cursor: pointer; - color: var(--text-primary); - width: 40px; - height: 40px; - border-radius: 12px; - margin-right: 16px; - display: flex; - align-items: center; - justify-content: center; -} - -.search-title { - font-size: 1.4rem; - font-weight: 800; - color: var(--text-primary); - letter-spacing: -0.02em; -} - -.search-actions-header { - display: flex; - justify-content: flex-end; - padding: 8px 0 16px; - border-bottom: 1px solid var(--border-color); - margin-bottom: 8px; -} - -.clear-map-btn { - display: flex; - align-items: center; - gap: 8px; - background: rgba(239, 68, 68, 0.1); - color: #ef4444; - border: 1px solid rgba(239, 68, 68, 0.2); - padding: 8px 16px; - border-radius: 12px; - font-size: 0.9rem; - font-weight: 700; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -.clear-map-btn:hover { - background: rgba(239, 68, 68, 0.2); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2); -} - -.clear-map-btn:active { - transform: scale(0.95); -} - -.clear-map-btn .material-icons { - font-size: 18px; -} - -.uber-results { - margin-top: 12px; - max-height: 55vh; /* Ajustado para dar espacio a la barra inferior */ - overflow-y: auto; - padding-bottom: 120px; /* Suficiente espacio para que no lo tape la barra de navegación */ -} - -.uber-result-item { - display: flex; - align-items: center; - gap: 16px; - padding: 16px; - cursor: pointer; - border-radius: 16px; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - margin-bottom: 4px; -} - -.uber-result-item:hover { - background: var(--hover-bg); - transform: translateX(8px); -} - -.selected-route { - background: var(--active-bg); - border: 1px solid var(--active-color); -} - -.check-icon { - color: var(--active-color); - margin-left: auto; -} - -.result-icon { - width: 44px; - height: 44px; - background: var(--bg-secondary); - border-radius: 14px; - display: flex; - align-items: center; - justify-content: center; - color: var(--active-color); - border: 1px solid var(--border-color); -} - -.result-name { - font-weight: 700; - color: var(--text-primary); - font-size: 1.1rem; -} - -.result-address { - font-size: 0.9rem; - color: var(--text-secondary); -} - -/* Uber Slide Animation - Fluid with scale */ -.uber-slide-enter-active { - transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); -} -.uber-slide-leave-active { - transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); -} - -.uber-slide-enter-from, -.uber-slide-leave-to { - opacity: 0; - transform: translateY(20px) scale(0.95); -} - -/* Reposicion de elementos fijos */ -.map-floating-controls { - position: fixed; - bottom: 85px; - right: 16px; - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - z-index: 1100; - transition: bottom 0.6s cubic-bezier(0.32, 0.72, 0, 1); -} - -.promos-badge-wrapper { - cursor: pointer; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - z-index: 10; - display: flex; - align-items: center; - justify-content: center; -} - -.promos-badge-wrapper:hover { - transform: scale(1.1); -} - -.close-promos-icon { - width: 50px; - height: 50px; - border-radius: 50%; - border: none; - background: #ef4444; - color: white; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4); - cursor: pointer; -} - .location-loader-btn { background: var(--header-bg); backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); border: 1px solid var(--border-color); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; width: 50px; height: 50px; border-radius: 50%; color: var(--active-color); box-shadow: var(--shadow); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - z-index: 1001; } -.location-loader-btn:hover { - transform: scale(1.1); - background: var(--hover-bg); -} - -.location-loader-btn:active { - transform: scale(0.85); - background: var(--active-bg); -} - -.location-loader-btn .material-icons { - font-size: 26px; -} - - -.promos-toggle-btn { - width: 60px; - height: 60px; - border-radius: 20px; - background: var(--active-color); - color: #101820; - border: none; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 8px 25px rgba(254, 231, 21, 0.4); - cursor: pointer; - transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; -} - -.promos-toggle-btn.active { - background: var(--text-primary); - color: var(--bg-primary); -} - -.promos-toggle-btn .material-icons { - font-size: 28px; -} - -.notification-dot { - position: absolute; - top: -4px; - right: -4px; - width: 14px; - height: 14px; - background: #f44336; - border-radius: 50%; - border: 2px solid var(--bg-primary); - animation: pulse-dot 2s infinite; -} - -@keyframes pulse-dot { - 0% { transform: scale(1); } - 50% { transform: scale(1.2); } - 100% { transform: scale(1); } -} - -.pulse { - animation: pulse-animation 2s infinite; -} - -@keyframes pulse-animation { - 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(254, 231, 21, 0.7); } - 70% { transform: scale(1.1); box-shadow: 0 0 0 15px rgba(254, 231, 21, 0); } - 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(254, 231, 21, 0); } -} - -/* Center sheet transition - Fluid Pop */ -.sheet-slide-enter-active { - transition: all 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); -} -.sheet-slide-leave-active { - transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); -} -.sheet-slide-enter-from, -.sheet-slide-leave-to { - transform: translate(-50%, -40%) scale(0.85); /* Emerge ligeramente desde abajo hacia el centro */ - opacity: 0; -} - -.location-button .material-icons { font-size: 24px; } - -/* Responsive */ -@media (max-width: 900px) { - .uber-search-container { top: 80px; } - - .map-floating-controls { - bottom: 130px; - right: 14px; - } - - .offers-sheet { - bottom: 60px; - } -} - -/* Modal Simple Styles (already mostly covered) */ .promo-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); - z-index: 3000; + z-index: 4000; display: flex; align-items: center; justify-content: center; } + .promo-modal-content { background: var(--card-bg); width: 90%; max-width: 450px; - border-radius: 20px; overflow: hidden; + border-radius: 24px; overflow: hidden; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); } + .promo-header-modal { position: relative; height: 200px; } .promo-img-modal { width: 100%; height: 100%; object-fit: cover; } .promo-badge-modal { position: absolute; bottom: 0; left: 0; background: #EAB308; color: #000; padding: 5px 15px; font-weight: 800; border-top-right-radius: 12px; } .promo-body-modal { padding: 25px; } .promo-title-modal { font-size: 1.5rem; font-weight: 800; margin-bottom: 10px; } .promo-biz { color: var(--active-color); font-weight: 700; margin-bottom: 15px; } -.promo-actions-modal { padding: 0 25px 25px; display: flex; gap: 10px; } -.business-detail-btn-modal { flex: 1; background: var(--active-color); color: #000; border: none; padding: 15px; border-radius: 10px; font-weight: 800; cursor: pointer; transition: all 0.2s; } -.business-detail-btn-modal:hover { transform: scale(1.02); box-shadow: 0 4px 12px rgba(234, 179, 8, 0.3); } -.close-modal-btn { - position: absolute; - top: 15px; - right: 15px; - background: rgba(0,0,0,0.5); - border: none; - border-radius: 50%; - width: 40px; - height: 40px; - color: white; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: background 0.2s; +.promo-actions-modal { padding: 0 25px 25px; } +.business-detail-btn-modal { width: 100%; background: var(--active-color); color: #000; border: none; padding: 15px; border-radius: 12px; font-weight: 800; cursor: pointer; } + +.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.3s ease; } +.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; } + +.pulse { animation: pulse-animation 2s infinite; } +@keyframes pulse-animation { + 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(254, 231, 21, 0.7); } + 70% { transform: scale(1.1); box-shadow: 0 0 0 15px rgba(254, 231, 21, 0); } + 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(254, 231, 21, 0); } } -.close-modal-btn:hover { - background: rgba(0,0,0,0.8); -} - -.tourist-badge { - background: #4CAF50 !important; -} - -.business-category-chip { - display: inline-block; - padding: 4px 12px; - background: var(--bg-secondary); - border-radius: 100px; - font-size: 0.75rem; - font-weight: 700; - color: var(--active-color); - margin-bottom: 15px; -} - -.business-detail-item { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 10px; - color: var(--text-secondary); -} - -.business-detail-item .material-icons { - font-size: 1.2rem; - color: var(--active-color); -} - -.call-btn { - background: #1976D2 !important; - text-decoration: none; -} - -/* Google Maps Style Navigation Card */ -.navigation-summary-card { - position: absolute; - bottom: 0px; - left: 0; - right: 0; - background: white; - margin: 12px; - padding: 16px; - border-radius: 12px; - box-shadow: 0 4px 25px rgba(0,0,0,0.2); - display: flex; - flex-direction: column; - z-index: 1000; - overflow: hidden; - border: 1px solid rgba(0,0,0,0.05); -} - -.nav-card-accent { - position: absolute; - top: 0; - left: 0; - right: 0; - height: 4px; - background: #4285F4; -} - -.nav-content { - display: flex; - justify-content: space-between; - align-items: center; -} - -.nav-left { - flex: 1; -} - -.nav-stats { - display: flex; - align-items: baseline; - gap: 8px; - margin-bottom: 2px; -} - -.nav-time { - font-size: 1.4rem; - font-weight: 700; - color: #1a73e8; -} - -.nav-dist { - font-size: 1rem; - color: #5f6368; - font-weight: 500; -} - -.nav-destination { - font-size: 0.9rem; - color: #202124; - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 250px; -} - -.nav-btn-close { - background: #f1f3f4; - border: none; - width: 36px; - height: 36px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: #5f6368; - transition: all 0.2s; -} - -.nav-btn-close:hover { - background: #e8eaed; - color: #202124; -} - -@media (prefers-color-scheme: dark) { - .navigation-summary-card { - background: #202124; - border-color: rgba(255,255,255,0.1); - } - .nav-time { color: #8ab4f8; } - .nav-dist { color: #bdc1c6; } - .nav-destination { color: #e8eaed; } - .nav-btn-close { background: #3c4043; color: #bdc1c6; } -} - -.sheet-fav-pos { - position: absolute; - top: 6px; - right: 6px; - z-index: 10; -} -.promo-modal-fav { - position: absolute; - top: 15px; - left: 15px; - z-index: 10; +@media (max-width: 600px) { + .map-floating-controls { bottom: 100px; } } diff --git a/frontend/src/views/StrategicAnalytics.vue b/frontend/src/views/StrategicAnalytics.vue index cd216f1..98d2513 100644 --- a/frontend/src/views/StrategicAnalytics.vue +++ b/frontend/src/views/StrategicAnalytics.vue @@ -263,11 +263,13 @@ const maxStopCount = computed(() => { 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..." + // OPTIMIZACIÓN: Carga dinámica de librerías pesadas para no afectar el rendimiento inicial + const [{ jsPDF }, html2canvas] = await Promise.all([ + import('jspdf'), + import('html2canvas').then(m => m.default) + ]); + const date = new Date().toLocaleDateString('es-ES', { month: 'long', year: 'numeric' }); const doc = new jsPDF('p', 'mm', 'a4'); const pageWidth = doc.internal.pageSize.getWidth(); @@ -393,25 +395,29 @@ const getHealthLabel = (rate: any) => (parseFloat(rate) > 20 ? 'Alta' : parseFlo onMounted(async () => { try { - // Get user count - const { count: userCount } = await supabase.from('users').select('*', { count: 'exact', head: true }).eq('is_active', true) + // Load all data in parallel + const [ + { count: userCount }, + { data: shuttles }, + { data: routes }, + { data: businesses } + ] = await Promise.all([ + supabase.from('users').select('*', { count: 'exact', head: true }).eq('is_active', true), + supabase.from('shuttles').select('id, route_name'), + supabase.from('routes').select('id, name'), + supabase.from('businesses').select('id, name') + ]) - // Get shuttle stats - const { data: shuttles } = await supabase.from('shuttles').select('id, route_name') const shuttleStats: any = {} for (const s of (shuttles || [])) { shuttleStats[s.route_name || s.id] = { views: Math.floor(Math.random() * 100), contacts: Math.floor(Math.random() * 20) } } - // Get route stats - const { data: routes } = await supabase.from('routes').select('id, name') const routeStats: any = {} for (const r of (routes || [])) { routeStats[r.name || r.id] = { views: Math.floor(Math.random() * 80), contacts: Math.floor(Math.random() * 15) } } - // Get business stats - const { data: businesses } = await supabase.from('businesses').select('id, name') const bizStats: any = {} for (const b of (businesses || [])) { bizStats[b.name || b.id] = { views: Math.floor(Math.random() * 60), promos: Math.floor(Math.random() * 10) }
Métricas en tiempo real del ecosistema SIBU
Procesando datos...
Sin registros
{{ t('map.loadingMap') }}
{{ selectedPromo.description }}