feat: optimización integral y auditoría de rendimiento SIBU 2.0.1

This commit is contained in:
2026-03-01 17:35:13 -05:00
parent 66b76cee61
commit 6ae0e7b0bf
14 changed files with 917 additions and 2170 deletions

View File

@ -0,0 +1,132 @@
<template>
<Transition name="banner-slide">
<div
v-if="isVisible"
class="best-stop-banner-compact"
>
<div class="banner-icon-bg">
<span class="material-icons text-white text-[16px]">directions_bus</span>
</div>
<div class="flex flex-col flex-1 truncate ml-2" style="min-width: 0;">
<span class="text-[9px] uppercase font-bold text-gray-500 dark:text-gray-400 leading-none">{{ t('map.arrivalTime') }}</span>
<span class="trigger-text-compact truncate leading-tight">{{ stopName }}</span>
</div>
<div class="eta-badge">
<template v-if="isLoading">
<div class="eta-loader"></div>
</template>
<template v-else-if="hasActiveBuses">
<span class="eta-value">{{ etaValue }}</span>
<span class="eta-unit">min</span>
</template>
<template v-else>
<span class="eta-unit">-- min</span>
</template>
</div>
<button @click.stop="$emit('close')" class="ml-2 p-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<span class="material-icons text-[18px] text-gray-400 hover:text-red-500">close</span>
</button>
</div>
</Transition>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const props = defineProps<{
isVisible: boolean
stopName: string
isLoading: boolean
hasActiveBuses: boolean
etaValue: number | string
}>()
defineEmits(['close'])
const { t } = useI18n()
</script>
<style scoped>
.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;
}
.banner-icon-bg {
width: 28px;
height: 28px;
background: var(--active-color);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.trigger-text-compact {
font-size: 0.85rem;
font-weight: 700;
}
.eta-badge {
background: var(--bg-secondary);
padding: 4px 10px;
border-radius: 8px;
display: flex;
align-items: baseline;
gap: 2px;
margin-left: 8px;
}
.eta-value {
font-weight: 900;
font-size: 1rem;
color: var(--active-color);
}
.eta-unit {
font-size: 0.65rem;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
}
.eta-loader {
width: 14px;
height: 14px;
border: 2px solid rgba(254, 231, 21, 0.2);
border-top-color: var(--active-color);
border-radius: 50%;
animation: spin 1s infinite linear;
}
@keyframes spin { to { transform: rotate(360deg); } }
.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;
}
</style>

View File

@ -0,0 +1,268 @@
<template>
<Transition name="sheet-slide">
<div v-if="isOpen && coupons.length > 0" class="offers-sheet">
<!-- Header -->
<div class="sheet-header">
<div class="sheet-title-group">
<span class="sheet-title">{{ t('coupons.title') }}</span>
</div>
<button class="sheet-close" @click="$emit('close')">
<span class="material-icons">close</span>
</button>
</div>
<!-- Card area with nav arrows -->
<div class="sheet-card-area">
<button class="sheet-nav" @click="$emit('prev')" :disabled="coupons.length < 2">
<span class="material-icons">chevron_left</span>
</button>
<Transition name="carousel-slide" mode="out-in">
<div
v-if="currentPromo"
:key="currentPromo.id"
class="sheet-card"
:style="{ backgroundImage: `url(${getImageUrl(currentPromo.image_url, 'coupon')})` }"
@mouseenter="$emit('pause')"
@touchstart="$emit('pause')"
@mouseleave="$emit('resume')"
>
<div class="sheet-card-overlay">
<div class="sheet-info">
<span class="sheet-biz-name">{{ currentPromo.business?.name || 'Local' }}</span>
<h3 class="sheet-promo-title">{{ currentPromo.title }}</h3>
<div class="sheet-actions">
<button class="sheet-cta" @click="$emit('promo-click', currentPromo)">{{ t('coupons.viewDetails') }}</button>
<span v-if="currentPromo.discount_percentage" class="sheet-discount-tag">-{{ currentPromo.discount_percentage }}%</span>
</div>
</div>
</div>
</div>
</Transition>
<button class="sheet-nav" @click="$emit('next')" :disabled="coupons.length < 2">
<span class="material-icons">chevron_right</span>
</button>
</div>
<!-- Dots -->
<div class="sheet-dots" v-if="coupons.length > 1">
<div
v-for="(_, i) in coupons"
:key="i"
class="sheet-dot"
:class="{ 'sheet-dot--active': i === currentIndex }"
@click="$emit('update:index', i)"
></div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { getImageUrl } from '@/utils/imageUrl'
const props = defineProps<{
isOpen: boolean
coupons: any[]
currentIndex: number
}>()
defineEmits(['close', 'prev', 'next', 'pause', 'resume', 'promo-click', 'update:index'])
const { t } = useI18n()
const currentPromo = computed(() => {
if (props.coupons.length === 0) return null
const idx = props.currentIndex % props.coupons.length
return props.coupons[idx]
})
</script>
<style scoped>
.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;
padding: 12px 0 0;
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-header {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px 12px;
}
.sheet-title {
font-size: 1.5rem;
font-weight: 900;
color: #101820;
text-align: center;
letter-spacing: -0.02em;
}
@media (prefers-color-scheme: dark) {
.sheet-title { color: #FFFFFF; }
}
.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-card-area {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
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;
}
.sheet-nav:first-of-type { left: 12px; }
.sheet-nav:last-of-type { right: 12px; }
.sheet-nav:disabled { opacity: 0.1; }
.sheet-card {
width: 100%;
height: 200px;
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-biz-name {
font-size: 0.75rem;
font-weight: 800;
color: #fee715;
text-transform: uppercase;
}
.sheet-promo-title {
margin: 0;
font-size: 1.1rem;
font-weight: 900;
color: #fff;
margin-bottom: 0.5rem;
}
.sheet-cta {
background: var(--active-color);
color: #101820;
border: none;
padding: 8px 20px;
border-radius: 100px;
font-weight: 800;
cursor: pointer;
}
.sheet-discount-tag {
background: #f43f5e;
color: #fff;
font-size: 0.75rem;
font-weight: 900;
padding: 0.25rem 0.6rem;
border-radius: 8px;
}
.sheet-dots {
display: flex;
justify-content: center;
gap: 6px;
padding: 0.25rem 0 0.25rem;
}
.sheet-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border-color);
cursor: pointer;
transition: all 0.25s;
}
.sheet-dot--active {
width: 20px;
border-radius: 4px;
background: var(--active-color);
}
/* Animations */
.sheet-slide-enter-active, .sheet-slide-leave-active { transition: all 0.5s ease; }
.sheet-slide-enter-from, .sheet-slide-leave-to { transform: translate(-50%, 100%); opacity: 0; }
.carousel-slide-enter-active, .carousel-slide-leave-active { transition: all 0.5s ease; }
.carousel-slide-enter-from { opacity: 0; transform: translateX(40px); }
.carousel-slide-leave-to { opacity: 0; transform: translateX(-40px); }
</style>

View File

@ -0,0 +1,233 @@
<template>
<div class="uber-search-container" :class="{ 'compact-mode': isCompact }">
<!-- Floating Triggers -->
<div v-if="!showPanel" class="triggers-row">
<!-- Shrunk Trigger (Icon only) -->
<div
v-if="isRouteActive"
class="uber-search-trigger circular"
@click="$emit('open')"
:title="t('map.search')"
>
<span class="material-icons">search</span>
</div>
<!-- Normal Trigger -->
<div
v-else
class="uber-search-trigger-compact"
@click="$emit('open')"
>
<span class="material-icons search-icon">directions_bus</span>
<span class="trigger-label">{{ t('map.viewRoutes') }}</span>
</div>
<!-- Slot for additional triggers like ArrivalBanner -->
<slot name="extra-triggers"></slot>
</div>
<!-- Uber-style Search Panel -->
<Transition name="uber-slide">
<div v-if="showPanel" class="uber-search-panel">
<div class="uber-search-header">
<button class="back-btn" @click="$emit('close')">
<span class="material-icons">arrow_back</span>
</button>
<div class="search-title">{{ t('map.availableRoutes') }}</div>
</div>
<!-- Results -->
<div class="uber-results custom-scrollbar">
<div
v-for="route in allRoutes"
:key="route.id"
class="uber-result-item"
:class="{ 'selected-route': route.id === selectedRouteId && wasSelectedFromMap }"
@click="$emit('select-route', route)"
>
<div class="result-icon">
<span class="material-icons">directions_bus</span>
</div>
<div class="result-content">
<div class="result-name">{{ route.name }}</div>
<div class="result-address">{{ t('map.busRoute') }}</div>
</div>
<span class="material-icons check-icon">
{{ route.id === selectedRouteId ? 'check_circle' : 'chevron_right' }}
</span>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const props = defineProps<{
showPanel: boolean
isCompact: boolean
isRouteActive: boolean
allRoutes: any[]
selectedRouteId: string | null
wasSelectedFromMap: boolean
}>()
defineEmits(['open', 'close', 'select-route'])
const { t } = useI18n()
</script>
<style scoped>
.uber-search-container {
position: fixed;
top: 90px;
left: 16px;
right: 16px;
z-index: 1100;
pointer-events: none;
}
.uber-search-container > * {
pointer-events: auto;
}
.uber-search-trigger {
background: var(--header-bg);
backdrop-filter: blur(20px);
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
padding: 0 16px;
box-shadow: var(--shadow);
cursor: pointer;
border: 1px solid var(--border-color);
width: 100%;
max-width: 500px;
}
.uber-search-trigger-compact {
background: var(--active-color) !important;
color: #101820 !important;
height: 44px;
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;
}
.trigger-label {
font-size: 0.9rem;
font-weight: 800;
text-transform: uppercase;
}
.uber-search-trigger.circular {
width: 44px;
padding: 0;
justify-content: center;
}
.triggers-row {
display: flex;
gap: 12px;
align-items: center;
}
.uber-search-panel {
background: var(--header-bg);
backdrop-filter: blur(20px);
border-radius: 24px;
padding: 24px;
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
max-width: 500px;
margin: 0 auto;
}
.uber-search-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.back-btn {
background: var(--bg-secondary);
border: none;
width: 40px;
height: 40px;
border-radius: 20px;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.search-title {
font-size: 1.25rem;
font-weight: 800;
}
.uber-results {
max-height: 400px;
overflow-y: auto;
}
.uber-result-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
border-radius: 16px;
cursor: pointer;
transition: all 0.2s;
}
.uber-result-item:hover {
background: var(--hover-bg);
}
.selected-route {
background: rgba(254, 231, 21, 0.1);
border: 1px solid var(--active-color);
}
.result-icon {
width: 48px;
height: 48px;
background: var(--bg-secondary);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--active-color);
}
.result-content {
flex: 1;
}
.result-name {
font-weight: 800;
color: var(--text-primary);
}
.result-address {
font-size: 0.8rem;
color: var(--text-secondary);
}
.check-icon {
color: var(--active-color);
}
.uber-slide-enter-active, .uber-slide-leave-active { transition: all 0.5s ease; }
.uber-slide-enter-from, .uber-slide-leave-to { transform: translateY(20px); opacity: 0; }
</style>