- Replace dropdown filter with horizontal scrollable chip pills (Todos/Rutas/Taxis/Negocios/Eventos) - Differentiated cards per item type: bus routes with yellow icon, taxis with photo, businesses in 2-col grid with image, events with gradient icon - Heart button to remove favorites (instead of X button) - more friendly UX - Improved empty state with SVG heart illustration and 'Explorar ahora' CTA - Full dark/light mode support via CSS variables (no hardcoded colors) - Sticky chips row for easy filtering while scrolling
660 lines
18 KiB
Vue
660 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useFavoritesStore } from '@/stores/favorites'
|
|
import { API_URL } from '@/services/apiClient'
|
|
|
|
const { t } = useI18n()
|
|
const router = useRouter()
|
|
const favoritesStore = useFavoritesStore()
|
|
const selectedFilter = ref('all')
|
|
|
|
const filters = [
|
|
{ key: 'all', label: 'Todos', icon: 'star' },
|
|
{ key: 'routes', label: 'Rutas', icon: 'directions_bus' },
|
|
{ key: 'taxis', label: 'Taxis', icon: 'local_taxi' },
|
|
{ key: 'businesses',label: 'Negocios', icon: 'store' },
|
|
{ key: 'coupons', label: 'Eventos', icon: 'confirmation_number' },
|
|
]
|
|
|
|
onMounted(async () => {
|
|
await favoritesStore.loadFavorites()
|
|
})
|
|
|
|
function getImageUrl(path?: string) {
|
|
if (!path) return `https://ui-avatars.com/api/?name=Favorito&background=fee715&color=101820&bold=true`
|
|
if (path.startsWith('http')) return path
|
|
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
|
|
}
|
|
|
|
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('/schedules')
|
|
else if (item.item_type === 'taxi') router.push('/taxi')
|
|
else if (item.item_type === 'business') router.push('/business/' + 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 totalFavorites = computed(() => favoritesStore.favorites.length)
|
|
const hasVisibleItems = computed(() =>
|
|
visibleRoutes.value.length + visibleTaxis.value.length +
|
|
visibleBusinesses.value.length + visibleCoupons.value.length > 0
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="fav-page">
|
|
|
|
<!-- ── Header ── -->
|
|
<header class="fav-header">
|
|
<h1 class="fav-title">Mis Favoritos</h1>
|
|
<p class="fav-count" v-if="totalFavorites > 0">
|
|
{{ totalFavorites }} guardado{{ totalFavorites !== 1 ? 's' : '' }}
|
|
</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">
|
|
<div class="spinner"></div>
|
|
<p>Cargando favoritos...</p>
|
|
</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">Nada guardado aún</h2>
|
|
<p class="empty-sub">Explora rutas, taxis y negocios para guardar tus favoritos aquí</p>
|
|
<button class="cta-btn" @click="router.push('/map')">
|
|
<span class="material-icons">explore</span>
|
|
Explorar ahora
|
|
</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">No tienes favoritos en esta categoría</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>Rutas</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">Toca para ver horarios</p>
|
|
</div>
|
|
<button class="heart-btn heart-btn--active" @click.stop="removeFavorite($event, item.item_type, item.item_id)" title="Quitar de favoritos">
|
|
<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>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">Taxi • Ver disponibilidad</p>
|
|
</div>
|
|
<button class="heart-btn heart-btn--active" @click.stop="removeFavorite($event, item.item_type, item.item_id)" title="Quitar de favoritos">
|
|
<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>Negocios</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">Negocio</span>
|
|
</div>
|
|
<div class="biz-body">
|
|
<p class="card-name">{{ item.item_name }}</p>
|
|
<p class="card-sub">Ver detalles →</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>Eventos</span>
|
|
</div>
|
|
<div class="card-list">
|
|
<div
|
|
v-for="item in visibleCoupons"
|
|
:key="item.id"
|
|
class="card card--row"
|
|
>
|
|
<div class="card-thumb card-thumb--event">
|
|
<span class="material-icons">local_activity</span>
|
|
</div>
|
|
<div class="card-info">
|
|
<p class="card-name">{{ item.item_name }}</p>
|
|
<span class="badge-avail">Disponible</span>
|
|
</div>
|
|
<button class="heart-btn heart-btn--active" @click.stop="removeFavorite($event, item.item_type, item.item_id)" title="Quitar de favoritos">
|
|
<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; }
|
|
|
|
/* 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);
|
|
}
|
|
|
|
.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>
|