Files
SIB/frontend/src/views/SchedulesView.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>