feat: optimización integral y auditoría de rendimiento SIBU 2.0.1
This commit is contained in:
132
frontend/src/components/map/ArrivalBanner.vue
Normal file
132
frontend/src/components/map/ArrivalBanner.vue
Normal 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>
|
||||
268
frontend/src/components/map/PromoCarousel.vue
Normal file
268
frontend/src/components/map/PromoCarousel.vue
Normal 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>
|
||||
233
frontend/src/components/map/SearchOverlay.vue
Normal file
233
frontend/src/components/map/SearchOverlay.vue
Normal 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>
|
||||
Reference in New Issue
Block a user