909 lines
26 KiB
Vue
909 lines
26 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|
import { useScheduleStore } from '@/stores/schedule'
|
|
import { useRouteStore } from '@/stores/route'
|
|
import { formatTo12Hour } from '@/utils/timeFormatter'
|
|
import { analyticsService } from '@/services/analyticsService'
|
|
import { useRoute } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
import LoadingBranded from '@/components/common/LoadingBranded.vue'
|
|
|
|
const route = useRoute()
|
|
const { t } = useI18n()
|
|
const scheduleStore = useScheduleStore()
|
|
const routeStore = useRouteStore()
|
|
|
|
const dropdownOpen = ref(false)
|
|
const dayFilter = ref<'all' | 'today' | 'tomorrow'>('today')
|
|
|
|
// SIBU | Estado local para independizar el selector de horarios del mapa
|
|
const localSelectedRouteId = ref<string | null>(null)
|
|
const localSelectedRouteName = ref<string | null>(null)
|
|
const hasLocalSelection = computed(() => localSelectedRouteId.value !== null)
|
|
|
|
// ── Tipos de día
|
|
const DAY_TYPES: Record<string, string> = {
|
|
'weekday': t('schedules.types.weekday'),
|
|
'weekend': t('schedules.types.weekend'),
|
|
'holiday': t('schedules.types.holiday'),
|
|
}
|
|
|
|
// ── Calcular estado del bus según horario
|
|
function getBusStatus(timeStr: string): 'departing' | 'ontime' | 'upcoming' | 'passed' {
|
|
if (!timeStr) return 'upcoming'
|
|
const now = new Date()
|
|
const [h, m] = timeStr.split(':').map(Number)
|
|
const schedDate = new Date()
|
|
schedDate.setHours(h || 0, m || 0, 0, 0)
|
|
|
|
const diffMin = (schedDate.getTime() - now.getTime()) / 60000
|
|
|
|
// Si el bus ya pasó (más de 2 minutos de margen de gracia)
|
|
if (diffMin < -2) return 'passed'
|
|
|
|
if (diffMin >= -2 && diffMin <= 10) return 'departing'
|
|
if (diffMin > 10 && diffMin <= 60) return 'ontime'
|
|
return 'upcoming'
|
|
}
|
|
|
|
// ── Calcular si el horario es "hoy" o "mañana" según tipo de día
|
|
// ── Calcular si el horario es "hoy" o "mañana" según tipo de día
|
|
function getScheduleDay(schedule: any): 'today' | 'tomorrow' | 'other' {
|
|
const now = new Date()
|
|
const tomorrow = new Date(now)
|
|
tomorrow.setDate(now.getDate() + 1)
|
|
|
|
const getDayType = (date: Date) => {
|
|
const dow = date.getDay() // 0=Dom, 6=Sab
|
|
return (dow === 0 || dow === 6) ? 'weekend' : 'weekday'
|
|
}
|
|
|
|
const todayType = getDayType(now)
|
|
const tomorrowType = getDayType(tomorrow)
|
|
|
|
// Comparar con el tipo del horario
|
|
// Nota: Si el horario es 'todos', cuenta para hoy y mañana (pero priorizamos hoy si pides hoy)
|
|
const type = (schedule.schedule_type as string) || 'todos'
|
|
|
|
const isToday = type === todayType || type === 'todos'
|
|
const isTomorrow = type === tomorrowType || type === 'todos'
|
|
|
|
if (isToday) return 'today'
|
|
if (isTomorrow) return 'tomorrow'
|
|
if (type === 'holiday') return 'other'
|
|
|
|
return 'other'
|
|
}
|
|
|
|
function getDayLabel(schedule: any): string {
|
|
const type = schedule.schedule_type || 'todos'
|
|
const now = new Date()
|
|
const todayType = (now.getDay() === 0 || now.getDay() === 6) ? 'weekend' : 'weekday'
|
|
|
|
if (type === 'todos') return t('coupons.active') // Or add "Daily" to JSON
|
|
if (type === todayType) return t('schedules.today') || 'Hoy'
|
|
return t('schedules.tomorrow') || 'Mañana'
|
|
}
|
|
|
|
// ── Filtrado de horarios
|
|
const filteredSchedules = computed(() => {
|
|
const now = new Date()
|
|
const tomorrow = new Date(now)
|
|
tomorrow.setDate(now.getDate() + 1)
|
|
|
|
const hhmmAhora = now.getHours() * 100 + now.getMinutes()
|
|
|
|
return scheduleStore.schedules.filter(s => {
|
|
const type = (s.schedule_type as string) || 'todos'
|
|
const todayType = (now.getDay() === 0 || now.getDay() === 6) ? 'weekend' : 'weekday'
|
|
const tomorrowType = (tomorrow.getDay() === 0 || tomorrow.getDay() === 6) ? 'weekend' : 'weekday'
|
|
|
|
const isActuallyToday = type === todayType || type === 'todos'
|
|
const isActuallyTomorrow = type === tomorrowType || type === 'todos'
|
|
|
|
// Filtro Hoy: Es hoy Y no ha pasado (o es de los que dice salir en este rango)
|
|
if (dayFilter.value === 'today') {
|
|
const [hStr, mStr] = (s.departure_time || '00:00').split(':')
|
|
const h = parseInt(hStr || '0')
|
|
const m = parseInt(mStr || '0')
|
|
const hhmmSched = h * 100 + m
|
|
const isPassed = hhmmSched < hhmmAhora - 2 // margen de 2 min
|
|
return isActuallyToday && !isPassed
|
|
}
|
|
|
|
// Filtro Mañana: Es mañana (sin importar si pasó la hora hoy)
|
|
if (dayFilter.value === 'tomorrow') {
|
|
return isActuallyTomorrow
|
|
}
|
|
|
|
// Filtro Todos: Mostrar todo
|
|
return true
|
|
}).sort((a, b) => (a.departure_time || '').localeCompare(b.departure_time || ''))
|
|
})
|
|
|
|
// ── Seleccionar ruta
|
|
function pickRoute(id: string, name: string) {
|
|
dropdownOpen.value = false
|
|
analyticsService.logEvent({
|
|
event_name: 'schedule_viewed',
|
|
item_id: name,
|
|
properties: { route_id: id }
|
|
})
|
|
|
|
// SIBU | Solo actualizamos estado local (Independiente del mapa)
|
|
localSelectedRouteId.value = id
|
|
localSelectedRouteName.value = name
|
|
scheduleStore.loadRouteSchedules(id)
|
|
}
|
|
|
|
// goToMap removed per request
|
|
|
|
|
|
// ── Cierre del dropdown al hacer clic fuera
|
|
function handleOutsideClick(e: MouseEvent) {
|
|
const target = e.target as HTMLElement
|
|
if (!target.closest('.route-selector')) dropdownOpen.value = false
|
|
}
|
|
|
|
async function fetchData(isBackground = false) {
|
|
await routeStore.loadRoutes(undefined, false, isBackground)
|
|
if (localSelectedRouteId.value) {
|
|
await scheduleStore.loadRouteSchedules(localSelectedRouteId.value, isBackground)
|
|
}
|
|
}
|
|
|
|
function handleRefocus() {
|
|
fetchData(true)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'Schedules' })
|
|
window.addEventListener('app-refocus', handleRefocus)
|
|
|
|
await routeStore.loadRoutes()
|
|
document.addEventListener('click', handleOutsideClick)
|
|
|
|
const queryRouteId = route.query.routeId as string
|
|
if (queryRouteId) {
|
|
const found = routeStore.allRoutes.find(r => r.id === queryRouteId)
|
|
if (found) pickRoute(found.id, found.name)
|
|
} else if (routeStore.selectedRouteId) {
|
|
// SIBU | Inicializamos con la ruta del mapa, pero a partir de aquí son independientes
|
|
const mapRoute = routeStore.allRoutes.find(r => r.id === routeStore.selectedRouteId)
|
|
if (mapRoute) {
|
|
localSelectedRouteId.value = mapRoute.id
|
|
localSelectedRouteName.value = mapRoute.name
|
|
scheduleStore.loadRouteSchedules(mapRoute.id)
|
|
}
|
|
}
|
|
})
|
|
|
|
const stopWatch = watch(
|
|
() => route.query.routeId,
|
|
(newId) => {
|
|
if (newId) {
|
|
const found = routeStore.allRoutes.find(r => r.id === newId as string)
|
|
if (found) pickRoute(found.id, found.name)
|
|
}
|
|
}
|
|
)
|
|
|
|
onUnmounted(() => {
|
|
stopWatch()
|
|
document.removeEventListener('click', handleOutsideClick)
|
|
window.removeEventListener('app-refocus', handleRefocus)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="sch-page">
|
|
|
|
<!-- ── HEADER ── -->
|
|
<header class="sch-header">
|
|
<h1 class="sch-title">{{ t('schedules.title') }}</h1>
|
|
<p class="sch-subtitle" v-if="!hasLocalSelection">
|
|
{{ t('schedules.selectRoute') }}
|
|
</p>
|
|
</header>
|
|
|
|
<!-- ── SELECTOR DE RUTA ── -->
|
|
<div class="selector-wrap">
|
|
<div class="route-selector" :class="{ 'route-selector--open': dropdownOpen }">
|
|
|
|
<!-- Botón disparador -->
|
|
<button
|
|
class="selector-trigger"
|
|
:class="{ 'selector-trigger--active': hasLocalSelection }"
|
|
@click.stop="dropdownOpen = !dropdownOpen"
|
|
:disabled="routeStore.isLoadingRoutes"
|
|
>
|
|
<div class="trigger-left">
|
|
<div class="trigger-icon">
|
|
<span class="material-icons">directions_bus</span>
|
|
</div>
|
|
<span class="trigger-text" :class="{ 'trigger-text--selected': hasLocalSelection }">
|
|
{{ routeStore.isLoadingRoutes ? t('schedules.loadingRoutes') : (localSelectedRouteName || t('schedules.placeholder')) }}
|
|
</span>
|
|
</div>
|
|
<span class="material-icons trigger-arrow" :class="{ 'trigger-arrow--up': dropdownOpen }">
|
|
expand_more
|
|
</span>
|
|
</button>
|
|
|
|
<!-- Lista desplegable -->
|
|
<Transition name="dropdown">
|
|
<div v-if="dropdownOpen" class="dropdown-list">
|
|
<div
|
|
v-if="routeStore.isLoadingRoutes"
|
|
class="dropdown-loading"
|
|
>
|
|
<div class="spinner-sm"></div>
|
|
<span>{{ t('schedules.loadingRoutes') }}</span>
|
|
</div>
|
|
<template v-else>
|
|
<button
|
|
v-for="r in routeStore.allRoutes"
|
|
:key="r.id"
|
|
class="dropdown-item"
|
|
:class="{ 'dropdown-item--active': r.id === localSelectedRouteId }"
|
|
@click.stop="pickRoute(r.id, r.name)"
|
|
>
|
|
<span class="material-icons dropdown-item-icon">directions_bus</span>
|
|
<span class="dropdown-item-name">{{ r.name }}</span>
|
|
<span v-if="r.id === localSelectedRouteId" class="material-icons dropdown-item-check">check</span>
|
|
</button>
|
|
<div v-if="routeStore.allRoutes.length === 0" class="dropdown-empty">
|
|
{{ t('schedules.noRoutesAvailable') }}
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── ESTADO VACÍO — sin ruta seleccionada ── -->
|
|
<div v-if="!hasLocalSelection" class="empty-state">
|
|
<div class="empty-illustration">
|
|
<svg width="80" height="80" viewBox="0 0 80 80" fill="none">
|
|
<circle cx="40" cy="38" r="24" stroke="var(--active-color)" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="6 4"/>
|
|
<line x1="40" y1="26" x2="40" y2="50" stroke="var(--active-color)" stroke-width="3.5" stroke-linecap="round"/>
|
|
<line x1="28" y1="38" x2="52" y2="38" stroke="var(--active-color)" stroke-width="3.5" stroke-linecap="round"/>
|
|
<line x1="33" y1="62" x2="47" y2="62" stroke="var(--active-color)" stroke-width="3" stroke-linecap="round"/>
|
|
<line x1="40" y1="56" x2="40" y2="62" stroke="var(--active-color)" stroke-width="3" stroke-linecap="round"/>
|
|
</svg>
|
|
</div>
|
|
<h2 class="empty-title">{{ t('schedules.schedules') }}</h2>
|
|
<p class="empty-sub">{{ t('schedules.placeholder') }}</p>
|
|
</div>
|
|
|
|
<!-- ── CONTENIDO con ruta seleccionada ── -->
|
|
<template v-else>
|
|
|
|
<!-- Badge EN VIVO -->
|
|
<div class="live-badge-wrap">
|
|
<div class="live-badge">
|
|
<span class="live-dot"></span>
|
|
<span>{{ t('schedules.upcoming') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chips de filtro día -->
|
|
<div class="day-chips">
|
|
<button class="day-chip" :class="{ 'day-chip--active': dayFilter === 'today' }" @click="dayFilter = 'today'">{{ t('schedules.today') || 'Hoy' }}</button>
|
|
<button class="day-chip" :class="{ 'day-chip--active': dayFilter === 'tomorrow' }" @click="dayFilter = 'tomorrow'">{{ t('schedules.tomorrow') || 'Mañana' }}</button>
|
|
<button class="day-chip" :class="{ 'day-chip--active': dayFilter === 'all' }" @click="dayFilter = 'all'">{{ t('common.all') || 'Todos' }}</button>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="scheduleStore.isLoading" class="state-center">
|
|
<LoadingBranded :message="t('schedules.loading') || 'Cargando horarios...'" icon="schedule" />
|
|
</div>
|
|
|
|
<!-- Sin resultados en el filtro -->
|
|
<div v-else-if="filteredSchedules.length === 0" class="empty-state empty-state--sm">
|
|
<span class="material-icons empty-cat-icon">schedule</span>
|
|
<p class="empty-sub">{{ t('schedules.noSchedules') }}</p>
|
|
<button class="chip-link" @click="dayFilter = 'all'">{{ t('schedules.viewAll') || 'Ver todos los horarios' }}</button>
|
|
</div>
|
|
|
|
<!-- Lista de horarios -->
|
|
<div v-else class="schedules-list">
|
|
<div
|
|
v-for="(schedule, i) in filteredSchedules"
|
|
:key="schedule.id"
|
|
class="schedule-card"
|
|
:class="dayFilter === 'today' ? `schedule-card--${getBusStatus(schedule.departure_time)}` : 'schedule-card--upcoming'"
|
|
>
|
|
<!-- Borde izquierdo decorativo -->
|
|
<div class="card-accent"></div>
|
|
|
|
<!-- Hora -->
|
|
<div class="card-time">
|
|
<span class="time-big">{{ formatTo12Hour(schedule.departure_time).split(' ')[0] }}</span>
|
|
<span class="time-ampm">{{ formatTo12Hour(schedule.departure_time).split(' ')[1] }}</span>
|
|
</div>
|
|
|
|
<!-- Info -->
|
|
<div class="card-info">
|
|
<div class="card-top-row">
|
|
<span class="day-tag" :class="dayFilter === 'today' ? `day-tag--${getScheduleDay(schedule)}` : 'day-tag--tomorrow'">
|
|
{{ dayFilter === 'tomorrow' ? t('schedules.tomorrow') : (dayFilter === 'all' ? getDayLabel(schedule) : getDayLabel(schedule)) }}
|
|
</span>
|
|
<span v-if="i === 0 && getBusStatus(schedule.departure_time) === 'departing'" class="departing-pulse">{{ t('schedules.departing') || 'SALIENDO' }}</span>
|
|
</div>
|
|
<p class="route-name">{{ localSelectedRouteName }}</p>
|
|
<p class="card-detail">
|
|
<span class="material-icons card-detail-icon">schedule</span>
|
|
{{ DAY_TYPES[schedule.schedule_type] || schedule.schedule_type }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Badge estado -->
|
|
<div class="status-badge" :class="dayFilter === 'today' ? `status-badge--${getBusStatus(schedule.departure_time)}` : 'status-badge--upcoming'">
|
|
<span class="material-icons status-icon">
|
|
{{ (dayFilter === 'today' && getBusStatus(schedule.departure_time) === 'departing') ? 'directions_run' :
|
|
(dayFilter === 'today' && getBusStatus(schedule.departure_time) === 'ontime') ? 'check_circle' :
|
|
(dayFilter === 'today' && getBusStatus(schedule.departure_time) === 'passed') ? 'history' : 'access_time' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* ═══════════════════════════════════════════
|
|
BASE
|
|
═══════════════════════════════════════════ */
|
|
.sch-page {
|
|
min-height: 100vh;
|
|
background: var(--bg-primary);
|
|
padding-bottom: 100px;
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════
|
|
HEADER
|
|
═══════════════════════════════════════════ */
|
|
.sch-header {
|
|
padding: 1.5rem 1.25rem 1rem;
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.sch-title {
|
|
font-size: 1.75rem;
|
|
font-weight: 900;
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
.sch-subtitle {
|
|
margin: 0.25rem 0 0;
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════
|
|
SELECTOR DE RUTA
|
|
═══════════════════════════════════════════ */
|
|
.selector-wrap {
|
|
padding: 1rem 1.25rem;
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
position: relative;
|
|
z-index: 50;
|
|
}
|
|
|
|
.route-selector { position: relative; }
|
|
|
|
/* Botón trigger */
|
|
.selector-trigger {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
padding: 1rem 1rem 1rem 0.875rem;
|
|
background: var(--bg-primary);
|
|
border: 1.5px solid var(--border-color);
|
|
border-radius: 1rem;
|
|
cursor: pointer;
|
|
transition: border-color 0.2s;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.selector-trigger:hover:not(:disabled),
|
|
.route-selector--open .selector-trigger {
|
|
border-color: var(--active-color);
|
|
}
|
|
|
|
.selector-trigger--active {
|
|
border-color: var(--active-color);
|
|
border-width: 2px;
|
|
}
|
|
|
|
.selector-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
.trigger-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.trigger-icon {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 0.625rem;
|
|
background: var(--active-color);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.trigger-icon .material-icons { font-size: 1.25rem; color: #101820; }
|
|
|
|
.trigger-text {
|
|
font-size: 0.9375rem;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.trigger-text--selected {
|
|
color: var(--text-primary);
|
|
font-weight: 800;
|
|
}
|
|
|
|
.trigger-arrow {
|
|
color: var(--text-secondary);
|
|
transition: transform 0.25s;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.trigger-arrow--up { transform: rotate(180deg); }
|
|
|
|
/* Dropdown */
|
|
.dropdown-list {
|
|
position: absolute;
|
|
top: calc(100% + 0.5rem);
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--bg-secondary);
|
|
border: 1.5px solid var(--border-color);
|
|
border-radius: 1rem;
|
|
overflow: hidden;
|
|
box-shadow: 0 12px 40px rgba(0,0,0,0.25);
|
|
z-index: 100;
|
|
}
|
|
|
|
.dropdown-loading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 1.25rem;
|
|
color: var(--text-secondary);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.dropdown-item {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.9rem 1rem;
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 1px solid var(--border-color);
|
|
color: var(--text-primary);
|
|
font-size: 0.9375rem;
|
|
font-weight: 600;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.dropdown-item:last-child { border-bottom: none; }
|
|
|
|
.dropdown-item:hover { background: var(--bg-primary); }
|
|
|
|
.dropdown-item--active {
|
|
color: var(--active-color);
|
|
background: rgba(254, 231, 21, 0.06);
|
|
}
|
|
|
|
.dropdown-item-icon {
|
|
font-size: 1.125rem;
|
|
color: var(--text-secondary);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.dropdown-item--active .dropdown-item-icon { color: var(--active-color); }
|
|
|
|
.dropdown-item-name { flex: 1; }
|
|
|
|
.dropdown-item-check {
|
|
font-size: 1.125rem;
|
|
color: var(--active-color);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.dropdown-empty {
|
|
padding: 1.25rem;
|
|
text-align: center;
|
|
color: var(--text-secondary);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
/* Animación dropdown */
|
|
.dropdown-enter-active,
|
|
.dropdown-leave-active {
|
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
|
}
|
|
.dropdown-enter-from,
|
|
.dropdown-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-8px);
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════
|
|
BADGE EN VIVO + BOTÓN MAPA
|
|
═══════════════════════════════════════════ */
|
|
.live-badge-wrap {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.875rem 1.25rem 0;
|
|
}
|
|
|
|
.live-badge {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.8125rem;
|
|
font-weight: 700;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.live-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: #22c55e;
|
|
animation: pulse-green 1.5s ease-in-out infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
@keyframes pulse-green {
|
|
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); }
|
|
50% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
|
|
}
|
|
|
|
.map-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
padding: 0.5rem 0.875rem;
|
|
background: var(--bg-secondary);
|
|
border: 1.5px solid var(--border-color);
|
|
border-radius: 99px;
|
|
color: var(--text-primary);
|
|
font-size: 0.8125rem;
|
|
font-weight: 700;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
transition: border-color 0.2s;
|
|
}
|
|
|
|
.map-btn:hover { border-color: var(--active-color); }
|
|
.map-btn .material-icons { font-size: 1rem; }
|
|
|
|
/* ═══════════════════════════════════════════
|
|
CHIPS DÍA
|
|
═══════════════════════════════════════════ */
|
|
.day-chips {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
padding: 0.75rem 1.25rem;
|
|
}
|
|
|
|
.day-chip {
|
|
padding: 0.4rem 0.875rem;
|
|
border-radius: 99px;
|
|
border: 1.5px solid var(--border-color);
|
|
background: var(--bg-primary);
|
|
color: var(--text-secondary);
|
|
font-size: 0.8125rem;
|
|
font-weight: 700;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
transition: all 0.18s;
|
|
}
|
|
|
|
.day-chip:hover { border-color: var(--active-color); color: var(--text-primary); }
|
|
|
|
.day-chip--active {
|
|
background: var(--active-color);
|
|
border-color: var(--active-color);
|
|
color: #101820;
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════
|
|
LISTA DE HORARIOS
|
|
═══════════════════════════════════════════ */
|
|
.schedules-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.625rem;
|
|
padding: 0 1.25rem;
|
|
max-width: 640px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.schedule-card {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.875rem;
|
|
background: var(--bg-secondary);
|
|
border: 1.5px solid var(--border-color);
|
|
border-radius: 1rem;
|
|
padding: 1rem;
|
|
position: relative;
|
|
overflow: hidden;
|
|
transition: transform 0.15s;
|
|
}
|
|
|
|
.schedule-card:hover { transform: translateX(4px); }
|
|
|
|
/* Variantes de estado */
|
|
.schedule-card--departing {
|
|
border-color: var(--active-color);
|
|
box-shadow: 0 0 16px rgba(254, 231, 21, 0.12);
|
|
}
|
|
|
|
.schedule-card--ontime { border-left-color: #22c55e; border-left-width: 3px; }
|
|
.schedule-card--upcoming { border-left-color: #3b82f6; border-left-width: 3px; }
|
|
|
|
/* Acento izquierdo */
|
|
.card-accent {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 4px;
|
|
border-radius: 1rem 0 0 1rem;
|
|
}
|
|
|
|
.schedule-card--departing .card-accent { background: var(--active-color); }
|
|
.schedule-card--ontime .card-accent { background: #22c55e; }
|
|
.schedule-card--upcoming .card-accent { background: #3b82f6; }
|
|
|
|
/* Hora */
|
|
.card-time {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
min-width: 64px;
|
|
padding-left: 0.25rem;
|
|
}
|
|
|
|
.time-big {
|
|
font-size: 1.5rem;
|
|
font-weight: 900;
|
|
color: var(--active-color);
|
|
letter-spacing: -0.03em;
|
|
line-height: 1;
|
|
}
|
|
|
|
.time-ampm {
|
|
font-size: 0.6875rem;
|
|
font-weight: 800;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
/* Info */
|
|
.card-info { flex: 1; min-width: 0; }
|
|
|
|
.card-top-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.day-tag {
|
|
font-size: 0.6875rem;
|
|
font-weight: 800;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
padding: 0.1rem 0.45rem;
|
|
border-radius: 99px;
|
|
}
|
|
|
|
.day-tag--today { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
|
|
.day-tag--tomorrow{ background: rgba(59, 130, 246, 0.15); color: #3b82f6; }
|
|
.day-tag--other { background: var(--bg-primary); color: var(--text-secondary); }
|
|
|
|
.departing-pulse {
|
|
font-size: 0.6rem;
|
|
font-weight: 900;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: #101820;
|
|
background: var(--active-color);
|
|
padding: 0.1rem 0.5rem;
|
|
border-radius: 99px;
|
|
animation: blink 1.2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes blink {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.6; }
|
|
}
|
|
|
|
/* Estado Pasado (Faded) */
|
|
.schedule-card--passed {
|
|
opacity: 0.5;
|
|
filter: grayscale(0.8);
|
|
border-left-color: #6b7280;
|
|
border-left-width: 3px;
|
|
transform: none !important; /* No hover effect for passed */
|
|
}
|
|
|
|
.schedule-card--passed .card-accent {
|
|
background: #6b7280;
|
|
}
|
|
|
|
.schedule-card--passed .time-big {
|
|
color: #6b7280;
|
|
}
|
|
|
|
.status-badge--passed {
|
|
background: rgba(107, 114, 128, 0.15);
|
|
color: #6b7280;
|
|
}
|
|
|
|
.route-name {
|
|
margin: 0;
|
|
font-size: 0.9375rem;
|
|
font-weight: 800;
|
|
color: var(--text-primary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.card-detail {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
margin: 0.2rem 0 0;
|
|
font-size: 0.8125rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.card-detail-icon { font-size: 0.875rem; }
|
|
|
|
/* Badge estado (derecha) */
|
|
.status-badge {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.status-badge--departing { background: rgba(254, 231, 21, 0.15); color: var(--active-color); }
|
|
.status-badge--ontime { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
|
|
.status-badge--upcoming { background: rgba(59, 130, 246, 0.15); color: #3b82f6; }
|
|
|
|
.status-icon { font-size: 1.25rem; }
|
|
|
|
/* ═══════════════════════════════════════════
|
|
ESTADO VACÍO
|
|
═══════════════════════════════════════════ */
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 3.5rem 2rem;
|
|
text-align: center;
|
|
max-width: 360px;
|
|
margin: 1.5rem auto 0;
|
|
}
|
|
|
|
.empty-state--sm { padding: 2rem 1.25rem; }
|
|
|
|
.empty-illustration {
|
|
width: 110px;
|
|
height: 110px;
|
|
border-radius: 50%;
|
|
background: var(--bg-secondary);
|
|
border: 1.5px dashed var(--border-color);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.empty-title {
|
|
font-size: 1.375rem;
|
|
font-weight: 900;
|
|
color: var(--text-primary);
|
|
margin: 0 0 0.625rem;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
.empty-sub {
|
|
font-size: 0.9375rem;
|
|
color: var(--text-secondary);
|
|
line-height: 1.55;
|
|
margin: 0;
|
|
}
|
|
|
|
.empty-cat-icon {
|
|
font-size: 3rem;
|
|
color: var(--text-secondary);
|
|
opacity: 0.35;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.chip-link {
|
|
margin-top: 1rem;
|
|
background: none;
|
|
border: 1.5px solid var(--active-color);
|
|
color: var(--active-color);
|
|
border-radius: 99px;
|
|
padding: 0.5rem 1.25rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 700;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
transition: background 0.2s, color 0.2s;
|
|
}
|
|
|
|
.chip-link:hover {
|
|
background: var(--active-color);
|
|
color: #101820;
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════
|
|
SPINNERS
|
|
═══════════════════════════════════════════ */
|
|
.state-center {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 3rem 1.25rem;
|
|
gap: 1rem;
|
|
color: var(--text-secondary);
|
|
font-size: 0.9375rem;
|
|
}
|
|
|
|
.spinner, .spinner-sm {
|
|
border: 2.5px solid var(--border-color);
|
|
border-top-color: var(--active-color);
|
|
border-radius: 50%;
|
|
animation: spin 0.75s linear infinite;
|
|
}
|
|
|
|
.spinner { width: 2rem; height: 2rem; }
|
|
.spinner-sm { width: 1rem; height: 1rem; }
|
|
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
</style>
|