Files
SIB/frontend/src/views/FavoritesView.vue
Hanzo_dev 35e2a6d632 fix(favorites): adjust favorite button visibility and functionality
- removed favorite button from Discover cards

- added call button for taxis in Favorites view

- added favorite button in Shuttle details

- added Shuttles category in Favorites view
2026-03-04 16:34:47 -05:00

785 lines
23 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useFavoritesStore } from '@/stores/favorites'
import { useAuthStore } from '@/stores/auth'
import { useTaxiStore } from '@/stores/taxi'
import { useI18n } from 'vue-i18n'
import { getImageUrl as utilGetImageUrl } from '@/utils/imageUrl'
import LoadingBranded from '@/components/common/LoadingBranded.vue'
const router = useRouter()
const { t } = useI18n()
const favoritesStore = useFavoritesStore()
const authStore = useAuthStore()
const taxiStore = useTaxiStore()
const selectedFilter = ref('all')
const filters = computed(() => [
{ key: 'all', label: t('common.all'), icon: 'star' },
{ key: 'routes', label: t('favorites.tabs.routes'), icon: 'directions_bus' },
{ key: 'shuttles', label: 'Viajes Turísticos', icon: 'airport_shuttle' },
{ key: 'taxis', label: t('favorites.tabs.taxis'), icon: 'local_taxi' },
{ key: 'businesses',label: t('favorites.tabs.businesses'), icon: 'store' },
{ key: 'coupons', label: t('favorites.tabs.coupons'), icon: 'confirmation_number' },
{ key: 'stops', label: t('navigation.routes'), icon: 'location_on' }, // Reusing navigation.routes or adding a specific one
])
// Mismo patrón que FavoriteButton: esperar a que Supabase complete INITIAL_SESSION
watch(
() => authStore.isAuthenticated,
(authenticated) => {
if (authenticated) {
favoritesStore.loadFavorites()
taxiStore.silentReload()
}
},
{ immediate: true }
)
function getTaxiPhone(id: string) {
const taxi = taxiStore.taxis.find(t => t.id === id)
return taxi?.phone_number || ''
}
function getImageUrl(path?: string) {
return utilGetImageUrl(path, 'business')
}
async function removeFavorite(event: Event, itemType: string, itemId: string) {
event.stopPropagation()
await favoritesStore.removeFavorite(itemType, itemId)
}
function navigateToItem(item: any) {
if (item.item_type === 'route') router.push({ path: '/schedules', query: { routeId: item.item_id } })
else if (item.item_type === 'taxi') router.push('/transporte/taxis')
else if (item.item_type === 'business') router.push('/business/' + item.item_id)
else if (item.item_type === 'coupon') router.push('/coupons')
else if (item.item_type === 'stop') router.push({ path: '/map', query: { stopId: item.item_id } })
else if (item.item_type === 'shuttle') router.push({ name: 'ShuttleDetalle', params: { id: item.item_id } })
}
const visibleRoutes = computed(() =>
(selectedFilter.value === 'all' || selectedFilter.value === 'routes') ? favoritesStore.routes : []
)
const visibleTaxis = computed(() =>
(selectedFilter.value === 'all' || selectedFilter.value === 'taxis') ? favoritesStore.taxis : []
)
const visibleBusinesses = computed(() =>
(selectedFilter.value === 'all' || selectedFilter.value === 'businesses') ? favoritesStore.businesses : []
)
const visibleCoupons = computed(() =>
(selectedFilter.value === 'all' || selectedFilter.value === 'coupons') ? favoritesStore.coupons : []
)
const visibleStops = computed(() =>
(selectedFilter.value === 'all' || selectedFilter.value === 'stops') ? favoritesStore.stops : []
)
const visibleShuttles = computed(() =>
(selectedFilter.value === 'all' || selectedFilter.value === 'shuttles') ? favoritesStore.shuttles : []
)
const totalFavorites = computed(() => favoritesStore.favorites.length)
const hasVisibleItems = computed(() =>
visibleRoutes.value.length + visibleTaxis.value.length +
visibleBusinesses.value.length + visibleCoupons.value.length +
visibleStops.value.length + visibleShuttles.value.length > 0
)
</script>
<template>
<div class="fav-page">
<!-- Header -->
<header class="fav-header">
<h1 class="fav-title">{{ t('favorites.title') }}</h1>
<p class="fav-count" v-if="totalFavorites > 0">
{{ t('favorites.count', { count: totalFavorites }) }}
</p>
</header>
<!-- Chips de filtro -->
<div class="chips-wrap">
<div class="chips-scroll">
<button
v-for="f in filters"
:key="f.key"
class="chip"
:class="{ 'chip--active': selectedFilter === f.key }"
@click="selectedFilter = f.key"
>
<span class="material-icons chip-icon">{{ f.icon }}</span>
{{ f.label }}
</button>
</div>
</div>
<!-- Loading -->
<div v-if="favoritesStore.isLoading" class="state-center">
<LoadingBranded :message="t('common.loading', 'Cargando favoritos')" icon="favorite_border" />
</div>
<template v-else>
<!-- Empty global -->
<div v-if="totalFavorites === 0" class="empty-state">
<div class="empty-illustration">
<svg width="100" height="100" viewBox="0 0 100 100" fill="none">
<path d="M50 80 C50 80 15 58 15 36 C15 24 24 16 34 16 C41 16 47 20 50 26 C53 20 59 16 66 16 C76 16 85 24 85 36 C85 58 50 80 50 80Z"
stroke="var(--active-color)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<line x1="50" y1="38" x2="50" y2="52" stroke="var(--active-color)" stroke-width="4" stroke-linecap="round"/>
<line x1="43" y1="45" x2="57" y2="45" stroke="var(--active-color)" stroke-width="4" stroke-linecap="round"/>
</svg>
</div>
<h2 class="empty-title">{{ t('favorites.empty.title') }}</h2>
<p class="empty-sub">{{ t('favorites.empty.description') }}</p>
<button class="cta-btn" @click="router.push('/map')">
<span class="material-icons">explore</span>
{{ t('favorites.cta.exploreNow') }}
</button>
</div>
<!-- Empty de categoría -->
<div v-else-if="!hasVisibleItems" class="empty-state empty-state--sm">
<span class="material-icons empty-cat-icon">search_off</span>
<p class="empty-sub">{{ t('favorites.empty.noResultsCategory') }}</p>
</div>
<!-- Contenido -->
<div v-else class="fav-content">
<!-- RUTAS -->
<section v-if="visibleRoutes.length > 0" class="fav-section">
<div class="section-label">
<span class="material-icons">directions_bus</span>
<span>{{ t('favorites.tabs.routes') }}</span>
</div>
<div class="card-list">
<div
v-for="item in visibleRoutes"
:key="item.id"
class="card card--row"
@click="navigateToItem(item)"
>
<div class="card-thumb card-thumb--yellow">
<span class="material-icons">directions_bus</span>
</div>
<div class="card-info">
<p class="card-name">{{ item.item_name }}</p>
<p class="card-sub">{{ t('favorites.viewSchedules') }}</p>
</div>
<button class="heart-btn heart-btn--active" @click.stop="removeFavorite($event, item.item_type, item.item_id)" :title="t('favorites.removeTitle')">
<span class="material-icons">favorite</span>
</button>
</div>
</div>
</section>
<!-- TAXIS -->
<section v-if="visibleTaxis.length > 0" class="fav-section">
<div class="section-label">
<span class="material-icons">local_taxi</span>
<span>{{ t('favorites.tabs.taxis') }}</span>
</div>
<div class="card-list">
<div
v-for="item in visibleTaxis"
:key="item.id"
class="card card--row"
@click="navigateToItem(item)"
>
<div class="card-thumb card-thumb--img">
<img :src="getImageUrl(item.item_image)" :alt="item.item_name" />
</div>
<div class="card-info">
<p class="card-name">{{ item.item_name }}</p>
<p class="card-sub">{{ t('favorites.availability') }}</p>
</div>
<a
v-if="getTaxiPhone(item.item_id)"
:href="'tel:' + getTaxiPhone(item.item_id)"
class="call-btn"
@click.stop
title="Llamar ahora"
>
<span class="material-icons">phone_in_talk</span>
</a>
<button class="heart-btn heart-btn--active" @click.stop="removeFavorite($event, item.item_type, item.item_id)" :title="t('favorites.removeTitle')">
<span class="material-icons">favorite</span>
</button>
</div>
</div>
</section>
<!-- SHUTTLES -->
<section v-if="visibleShuttles.length > 0" class="fav-section">
<div class="section-label">
<span class="material-icons">airport_shuttle</span>
<span>Viajes Turísticos</span>
</div>
<div class="card-list">
<div
v-for="item in visibleShuttles"
:key="item.id"
class="card card--row"
@click="navigateToItem(item)"
>
<div class="card-thumb card-thumb--img">
<img :src="getImageUrl(item.item_image)" :alt="item.item_name" />
</div>
<div class="card-info">
<p class="card-name">{{ item.item_name }}</p>
<p class="card-sub">Ver detalles</p>
</div>
<button class="heart-btn heart-btn--active" @click.stop="removeFavorite($event, item.item_type, item.item_id)">
<span class="material-icons">favorite</span>
</button>
</div>
</div>
</section>
<!-- NEGOCIOS -->
<section v-if="visibleBusinesses.length > 0" class="fav-section">
<div class="section-label">
<span class="material-icons">store</span>
<span>{{ t('favorites.tabs.businesses') }}</span>
</div>
<div class="biz-grid">
<div
v-for="item in visibleBusinesses"
:key="item.id"
class="biz-card"
@click="navigateToItem(item)"
>
<div class="biz-img">
<img :src="getImageUrl(item.item_image)" :alt="item.item_name" />
<button class="heart-btn heart-btn--active heart-btn--overlay" @click.stop="removeFavorite($event, item.item_type, item.item_id)">
<span class="material-icons">favorite</span>
</button>
<span class="biz-badge">{{ t('favorites.tabs.businesses') }}</span>
</div>
<div class="biz-body">
<p class="card-name">{{ item.item_name }}</p>
<p class="card-sub">{{ t('favorites.details') }}</p>
</div>
</div>
</div>
</section>
<!-- EVENTOS / CUPONES -->
<section v-if="visibleCoupons.length > 0" class="fav-section">
<div class="section-label">
<span class="material-icons">confirmation_number</span>
<span>{{ t('favorites.tabs.coupons') }}</span>
</div>
<div class="card-list">
<div
v-for="item in visibleCoupons"
:key="item.id"
class="card card--row"
@click="navigateToItem(item)"
>
<div class="card-thumb card-thumb--event">
<img v-if="item.item_image" :src="getImageUrl(item.item_image)" class="thumb-img" />
<span v-else class="material-icons">local_activity</span>
</div>
<div class="card-info">
<p class="card-name">{{ item.item_name }}</p>
<span class="badge-avail">{{ t('favorites.tabs.coupons') }}</span>
</div>
<button class="heart-btn heart-btn--active" @click.stop="removeFavorite($event, item.item_type, item.item_id)" :title="t('favorites.removeTitle')">
<span class="material-icons">favorite</span>
</button>
</div>
</div>
</section>
<!-- PARADAS -->
<section v-if="visibleStops.length > 0" class="fav-section">
<div class="section-label">
<span class="material-icons">location_on</span>
<span>{{ t('navigation.routes') }}</span>
</div>
<div class="card-list">
<div
v-for="item in visibleStops"
:key="item.id"
class="card card--row"
@click="navigateToItem(item)"
>
<div class="card-thumb card-thumb--blue">
<span class="material-icons">location_on</span>
</div>
<div class="card-info">
<p class="card-name">{{ item.item_name }}</p>
<span class="badge-avail">{{ t('favorites.saved') }}</span>
</div>
<button class="heart-btn heart-btn--active" @click.stop="removeFavorite($event, item.item_type, item.item_id)">
<span class="material-icons">favorite</span>
</button>
</div>
</div>
</section>
</div>
</template>
</div>
</template>
<style scoped>
/* ══════════════════════════════════════════
PÁGINA BASE
══════════════════════════════════════════ */
.fav-page {
min-height: 100vh;
background: var(--bg-primary);
padding-bottom: 90px;
}
/* ══════════════════════════════════════════
HEADER
══════════════════════════════════════════ */
.fav-header {
padding: 1.5rem 1.25rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.fav-title {
font-size: 1.75rem;
font-weight: 900;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.02em;
}
.fav-count {
margin: 0.25rem 0 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
}
/* ══════════════════════════════════════════
CHIPS
══════════════════════════════════════════ */
.chips-wrap {
background: var(--bg-secondary);
padding: 0.75rem 0 0.75rem;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 10;
}
.chips-scroll {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding: 0 1.25rem;
scrollbar-width: none;
}
.chips-scroll::-webkit-scrollbar { display: none; }
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 99px;
font-size: 0.8125rem;
font-weight: 700;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
border: 1.5px solid transparent;
background: var(--bg-primary);
color: var(--text-secondary);
border-color: var(--border-color);
transition: all 0.18s ease;
flex-shrink: 0;
}
.chip-icon { font-size: 1rem; }
.chip:hover {
color: var(--text-primary);
border-color: var(--active-color);
}
.chip--active {
background: var(--active-color);
color: #101820;
border-color: var(--active-color);
}
/* ══════════════════════════════════════════
CONTENIDO
══════════════════════════════════════════ */
.fav-content {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 2rem;
max-width: 640px;
margin: 0 auto;
}
/* ── Secciones ── */
.fav-section { display: flex; flex-direction: column; gap: 0.75rem; }
.section-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 800;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.section-label .material-icons {
font-size: 1.25rem;
color: var(--active-color);
}
/* ══════════════════════════════════════════
TARJETA FILA (rutas / taxis / eventos)
══════════════════════════════════════════ */
.card-list { display: flex; flex-direction: column; gap: 0.625rem; }
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 1rem;
transition: border-color 0.2s, transform 0.15s;
cursor: pointer;
}
.card:hover {
border-color: var(--active-color);
transform: translateX(4px);
}
.card--row {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.875rem;
}
/* Thumb */
.card-thumb {
width: 52px;
height: 52px;
border-radius: 0.75rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.card-thumb--yellow {
background: var(--active-color);
color: #101820;
}
.card-thumb--yellow .material-icons { font-size: 1.5rem; }
.card-thumb--img img {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-thumb--event {
background: linear-gradient(135deg, #FEE715, #F97316);
color: #101820;
}
.card-thumb--event .material-icons { font-size: 1.5rem; }
.card-thumb--blue {
background: #3b82f6;
color: white;
}
.card-thumb--blue .material-icons { font-size: 1.5rem; }
/* Info */
.card-info { flex: 1; min-width: 0; }
.card-name {
margin: 0;
font-size: 0.9375rem;
font-weight: 800;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-sub {
margin: 0.2rem 0 0;
font-size: 0.8125rem;
color: var(--text-secondary);
font-weight: 500;
}
.badge-avail {
display: inline-block;
margin-top: 0.25rem;
font-size: 0.6875rem;
font-weight: 800;
color: #16a34a;
background: rgba(22, 163, 74, 0.12);
border-radius: 99px;
padding: 0.15rem 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ══════════════════════════════════════════
BOTÓN CORAZÓN
══════════════════════════════════════════ */
.heart-btn {
width: 38px;
height: 38px;
border-radius: 50%;
border: 1.5px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s;
}
.heart-btn .material-icons { font-size: 1.125rem; }
.heart-btn--active {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.heart-btn--active:hover {
background: #ef4444;
border-color: #ef4444;
color: white;
transform: scale(1.1);
}
.call-btn {
background: var(--active-color);
border: none;
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #101820;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
margin-right: -4px;
}
.call-btn:hover {
transform: scale(1.1);
box-shadow: 0 4px 15px rgba(254, 231, 21, 0.3);
}
.call-btn .material-icons {
font-size: 20px;
}
.heart-btn--overlay {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(0,0,0,0.45);
border-color: transparent;
color: white;
backdrop-filter: blur(4px);
}
.heart-btn--overlay:hover {
background: #ef4444;
}
/* ══════════════════════════════════════════
GRID DE NEGOCIOS
══════════════════════════════════════════ */
.biz-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.biz-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 1rem;
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
}
.biz-card:hover {
border-color: var(--active-color);
transform: translateY(-4px);
}
.biz-img {
position: relative;
height: 100px;
overflow: hidden;
}
.biz-img img {
width: 100%;
height: 100%;
object-fit: cover;
}
.biz-badge {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
font-size: 0.6875rem;
font-weight: 800;
background: var(--active-color);
color: #101820;
padding: 0.15rem 0.5rem;
border-radius: 99px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.biz-body {
padding: 0.625rem 0.75rem;
}
.biz-body .card-name {
font-size: 0.875rem;
}
.biz-body .card-sub {
color: var(--active-color);
font-weight: 700;
}
/* ══════════════════════════════════════════
ESTADOS
══════════════════════════════════════════ */
.state-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 1.25rem;
gap: 1rem;
color: var(--text-secondary);
}
.spinner {
width: 2rem;
height: 2rem;
border: 2.5px solid var(--border-color);
border-top-color: var(--active-color);
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Empty state principal */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 3.5rem 2rem;
text-align: center;
max-width: 360px;
margin: 2rem auto 0;
}
.empty-state--sm { padding: 2rem 1.25rem; }
.empty-illustration {
width: 110px;
height: 110px;
border-radius: 50%;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
border: 1.5px dashed var(--border-color);
}
.empty-cat-icon {
font-size: 3rem;
color: var(--text-secondary);
opacity: 0.4;
margin-bottom: 0.75rem;
}
.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.5;
margin: 0 0 1.75rem;
}
.cta-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1.75rem;
background: var(--active-color);
color: #101820;
border: none;
border-radius: 99px;
font-size: 0.9375rem;
font-weight: 800;
font-family: inherit;
cursor: pointer;
box-shadow: 0 6px 20px rgba(254, 231, 21, 0.3);
transition: transform 0.15s, box-shadow 0.15s;
}
.cta-btn .material-icons { font-size: 1.25rem; }
.cta-btn:active { transform: scale(0.97); }
/* ══════════════════════════════════════════
RESPONSIVE
══════════════════════════════════════════ */
@media (max-width: 480px) {
.biz-grid {
grid-template-columns: repeat(2, 1fr);
}
.fav-title { font-size: 1.5rem; }
}
@media (min-width: 640px) {
.biz-grid { grid-template-columns: repeat(3, 1fr); }
}
</style>