Files
SIB/frontend/src/views/DiscoverView.vue

862 lines
25 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { businessService } from '@/services/businessService'
import type { Business } from '@/types'
import { useRouter } from 'vue-router'
import FavoriteButton from '@/components/FavoriteButton.vue'
import { analyticsService } from '@/services/analyticsService'
import { getImageUrl } from '@/utils/imageUrl'
const router = useRouter()
const businesses = ref<Business[]>([])
const isLoading = ref(true)
const error = ref<string | null>(null)
const searchQuery = ref('')
const selectedCategory = ref('Todas')
const selectedArea = ref('Todas')
// ── Categorías con emoji e ícono material
const CATEGORY_META: Record<string, { emoji: string; icon: string }> = {
'Todas': { emoji: '✨', icon: 'apps' },
'Restaurante': { emoji: '🍽️', icon: 'restaurant' },
'Hotel': { emoji: '🏨', icon: 'hotel' },
'Café': { emoji: '☕', icon: 'local_cafe' },
'Comercio': { emoji: '🏪', icon: 'store' },
'Turismo': { emoji: '🌄', icon: 'landscape' },
'Bebidas': { emoji: '🍹', icon: 'local_bar' },
}
function catEmoji(cat: string) {
return CATEGORY_META[cat]?.emoji ?? '📍'
}
function catIcon(cat: string) {
return CATEGORY_META[cat]?.icon ?? 'place'
}
async function loadBusinesses() {
isLoading.value = true
error.value = null
try {
businesses.value = await businessService.getAllBusinesses()
} catch (e) {
console.error('Error loading businesses:', e)
error.value = 'No se pudieron cargar los lugares. Revisa tu conexión.'
} finally {
isLoading.value = false
}
}
onMounted(() => {
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'Discover' })
loadBusinesses()
})
// ── Computados
const categories = computed<string[]>(() => {
const cats = new Set(businesses.value.map(b => b.category).filter(Boolean) as string[])
return ['Todas', ...Array.from(cats)]
})
const areas = computed<string[]>(() => {
const ars = new Set(businesses.value.map(b => b.area).filter(Boolean) as string[])
return [...Array.from(ars)]
})
const filteredBusinesses = computed(() => {
let list = businesses.value
if (selectedCategory.value !== 'Todas')
list = list.filter(b => b.category === selectedCategory.value)
if (selectedArea.value !== 'Todas')
list = list.filter(b => b.area === selectedArea.value)
if (searchQuery.value.trim())
list = list.filter(b =>
b.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
(b.area && b.area.toLowerCase().includes(searchQuery.value.toLowerCase())) ||
(b.category && b.category.toLowerCase().includes(searchQuery.value.toLowerCase()))
)
return list
})
// Primeros 2 para destacados cuando no hay filtro activo
const featuredBusinesses = computed(() =>
businesses.value.slice(0, 2)
)
// Resto del listado (sin los destacados) cuando no hay filtros
const gridBusinesses = computed(() => {
const hasFilter = selectedCategory.value !== 'Todas' || selectedArea.value !== 'Todas' || searchQuery.value.trim()
if (hasFilter) return filteredBusinesses.value
return businesses.value.slice(2)
})
const isFiltering = computed(() =>
selectedCategory.value !== 'Todas' || selectedArea.value !== 'Todas' || searchQuery.value.trim() !== ''
)
function handleExplore(biz: Business) {
analyticsService.logEvent({ event_name: 'promo_click', item_id: biz.name, properties: { business_id: biz.id } })
router.push('/business/' + biz.id)
}
function resetFilters() {
selectedCategory.value = 'Todas'
selectedArea.value = 'Todas'
searchQuery.value = ''
}
</script>
<template>
<div class="disc-page">
<!-- HEADER -->
<header class="disc-header">
<div class="header-body">
<h1 class="disc-title">¡Explora Chiriquí! 🌿</h1>
<p class="disc-sub">Descubre los mejores lugares cerca de ti</p>
</div>
<!-- Búsqueda -->
<div class="search-wrap">
<span class="material-icons search-icon">search</span>
<input
v-model="searchQuery"
type="text"
class="search-input"
placeholder="Buscar restaurantes, hoteles..."
/>
<button v-if="searchQuery" class="search-clear" @click="searchQuery = ''">
<span class="material-icons">close</span>
</button>
</div>
</header>
<!-- CHIPS DE CATEGORÍA -->
<div class="cat-chips-wrap">
<div class="cat-chips-scroll">
<button
v-for="cat in categories"
:key="cat"
class="cat-chip"
:class="{ 'cat-chip--active': selectedCategory === cat }"
@click="selectedCategory = cat"
>
{{ catEmoji(cat) }} {{ cat }}
</button>
</div>
</div>
<!-- LOADING -->
<div v-if="isLoading" class="state-center">
<div class="spinner"></div>
<p>Cargando lugares...</p>
</div>
<!-- ERROR -->
<div v-else-if="error" class="state-center">
<span class="material-icons" style="font-size: 3.5rem; color: #ef4444; opacity: 0.8; margin-bottom: 0.5rem;">error_outline</span>
<p style="font-weight: 600; color: var(--text-secondary);">{{ error }}</p>
<button class="cta-btn" style="margin-top: 1rem;" @click="loadBusinesses">
<span class="material-icons">refresh</span>
Reintentar
</button>
</div>
<template v-else>
<!-- VISTA CON FILTRO ACTIVO -->
<div v-if="isFiltering" class="disc-content">
<!-- Chips de área activos -->
<div v-if="areas.length > 0" class="area-chips">
<button
v-for="area in areas"
:key="area"
class="area-chip"
:class="{ 'area-chip--active': selectedArea === area }"
@click="selectedArea = selectedArea === area ? 'Todas' : area"
>
<span class="material-icons area-chip-icon">location_on</span>
{{ area }}
</button>
</div>
<!-- Contador de resultados -->
<div class="results-bar">
<span class="results-count">
{{ filteredBusinesses.length }}
{{ filteredBusinesses.length === 1 ? 'lugar' : 'lugares' }}
<template v-if="selectedCategory !== 'Todas'"> en {{ selectedCategory }}</template>
<template v-if="selectedArea !== 'Todas'"> · {{ selectedArea }}</template>
</span>
<button class="reset-btn" @click="resetFilters">
<span class="material-icons">refresh</span>
Limpiar
</button>
</div>
<!-- Grid de resultados -->
<TransitionGroup v-if="filteredBusinesses.length > 0" name="fade" tag="div" class="biz-grid">
<div
v-for="biz in filteredBusinesses"
:key="biz.id"
v-memo="[biz.id]"
class="biz-card"
@click="handleExplore(biz)"
>
<div class="biz-img-wrap">
<img
:src="getImageUrl(biz.image_url, 'business')"
:alt="biz.name"
loading="lazy"
decoding="async"
class="biz-img"
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'business')"
/>
<div class="biz-fav">
<FavoriteButton item-type="business" :item-id="biz.id" :item-name="biz.name" :item-image="biz.image_url || undefined" />
</div>
<span class="biz-cat-badge">
<span class="material-icons" style="font-size:0.875rem">{{ catIcon(biz.category || '') }}</span>
{{ biz.category }}
</span>
</div>
<div class="biz-body">
<p class="biz-name">{{ biz.name }}</p>
<p class="biz-area">
<span class="material-icons biz-area-icon">near_me</span>
{{ biz.area }}
</p>
</div>
</div>
</TransitionGroup>
<!-- Vacío -->
<div v-else class="empty-state">
<span class="material-icons empty-icon">search_off</span>
<h2 class="empty-title">Sin resultados</h2>
<p class="empty-sub">No encontramos lugares con ese filtro.</p>
<button class="cta-btn" @click="resetFilters">Ver todos los lugares</button>
</div>
<!-- CTA al final -->
<div v-if="filteredBusinesses.length > 0" class="more-card" @click="resetFilters">
<p class="more-card-title">¿Buscas algo más?</p>
<p class="more-card-sub">Explora sin filtros para descubrir todo</p>
<button class="cta-btn">Ver todo</button>
</div>
</div>
<!-- VISTA INICIO (sin filtros) -->
<div v-else class="disc-content">
<!-- CHIPS DE ÁREA -->
<div v-if="areas.length > 0" class="area-section">
<p class="section-label">🗺 Por Área</p>
<div class="area-chips">
<button
v-for="area in areas"
:key="area"
class="area-chip"
:class="{ 'area-chip--active': selectedArea === area }"
@click="selectedArea = selectedArea === area ? 'Todas' : area"
>
<span class="material-icons area-chip-icon">location_on</span>
{{ area }}
</button>
</div>
</div>
<!-- DESTACADOS -->
<div v-if="featuredBusinesses.length > 0" class="featured-section">
<p class="section-label"> Destacados</p>
<div class="featured-grid">
<div
v-for="biz in featuredBusinesses"
:key="biz.id"
v-memo="[biz.id]"
class="featured-card"
@click="handleExplore(biz)"
>
<img
:src="getImageUrl(biz.image_url, 'business')"
:alt="biz.name"
loading="lazy"
decoding="async"
class="featured-img"
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'business')"
/>
<div class="featured-gradient"></div>
<div class="featured-fav">
<FavoriteButton item-type="business" :item-id="biz.id" :item-name="biz.name" :item-image="biz.image_url || undefined" />
</div>
<div class="featured-info">
<span class="featured-cat">
<span class="material-icons" style="font-size:0.8rem">{{ catIcon(biz.category || '') }}</span>
{{ biz.category }}
</span>
<p class="featured-name">{{ biz.name }}</p>
<p class="featured-area">
<span class="material-icons" style="font-size:0.875rem">near_me</span>
{{ biz.area }}
</p>
</div>
</div>
</div>
</div>
<!-- TODOS LOS LUGARES -->
<div v-if="gridBusinesses.length > 0" class="all-section">
<p class="section-label">🏙 Todos los lugares</p>
<TransitionGroup name="fade" tag="div" class="biz-grid">
<div
v-for="biz in gridBusinesses"
:key="biz.id"
v-memo="[biz.id]"
class="biz-card"
@click="handleExplore(biz)"
>
<div class="biz-img-wrap">
<img
:src="getImageUrl(biz.image_url, 'business')"
:alt="biz.name"
loading="lazy"
decoding="async"
class="biz-img"
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'business')"
/>
<div class="biz-fav">
<FavoriteButton item-type="business" :item-id="biz.id" :item-name="biz.name" :item-image="biz.image_url || undefined" />
</div>
<span class="biz-cat-badge">
<span class="material-icons" style="font-size:0.875rem">{{ catIcon(biz.category || '') }}</span>
{{ biz.category }}
</span>
</div>
<div class="biz-body">
<p class="biz-name">{{ biz.name }}</p>
<p class="biz-area">
<span class="material-icons biz-area-icon">near_me</span>
{{ biz.area }}
</p>
</div>
</div>
</TransitionGroup>
</div>
<div v-if="businesses.length === 0" class="empty-state">
<span class="material-icons empty-icon">storefront</span>
<h2 class="empty-title">Sin lugares aún</h2>
<p class="empty-sub">Pronto habrá negocios y lugares turísticos disponibles aquí.</p>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* ═══════════════════════════════════════════
BASE
═══════════════════════════════════════════ */
.disc-page {
min-height: 100vh;
background: var(--bg-primary);
padding-bottom: 100px;
}
/* ═══════════════════════════════════════════
HEADER
═══════════════════════════════════════════ */
.disc-header {
padding: 1.25rem 1.25rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.disc-title {
font-size: 1.625rem;
font-weight: 900;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.02em;
}
.disc-sub {
margin: 0.2rem 0 0;
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
}
/* Search */
.search-wrap {
display: flex;
align-items: center;
gap: 0.625rem;
background: var(--bg-primary);
border: 1.5px solid var(--border-color);
border-radius: 0.875rem;
padding: 0.75rem 1rem;
transition: border-color 0.2s;
}
.search-wrap:focus-within { border-color: var(--active-color); }
.search-icon { font-size: 1.25rem; color: var(--active-color); flex-shrink: 0; }
.search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.9375rem;
color: var(--text-primary);
font-family: inherit;
outline: none;
}
.search-input::placeholder { color: var(--text-secondary); }
.search-clear {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
padding: 0;
}
.search-clear .material-icons { font-size: 1.125rem; }
/* ═══════════════════════════════════════════
CHIPS DE CATEGORÍA
═══════════════════════════════════════════ */
.cat-chips-wrap {
background: var(--bg-secondary);
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 10;
}
.cat-chips-scroll {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding: 0 1.25rem;
scrollbar-width: none;
}
.cat-chips-scroll::-webkit-scrollbar { display: none; }
.cat-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 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;
white-space: nowrap;
flex-shrink: 0;
transition: all 0.18s;
}
.cat-chip:hover { border-color: var(--active-color); color: var(--text-primary); }
.cat-chip--active {
background: var(--active-color);
border-color: var(--active-color);
color: #101820;
}
/* ═══════════════════════════════════════════
CONTENIDO
═══════════════════════════════════════════ */
.disc-content {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1.75rem;
max-width: 720px;
margin: 0 auto;
}
/* Etiqueta de sección */
.section-label {
font-size: 1rem;
font-weight: 800;
color: var(--text-primary);
margin: 0 0 0.875rem;
letter-spacing: -0.01em;
}
/* ═══════════════════════════════════════════
CHIPS DE ÁREA
═══════════════════════════════════════════ */
.area-section { display: flex; flex-direction: column; }
.area-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.area-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.875rem;
border-radius: 99px;
border: 1.5px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.8125rem;
font-weight: 700;
font-family: inherit;
cursor: pointer;
transition: all 0.18s;
}
.area-chip-icon { font-size: 0.875rem; }
.area-chip:hover { border-color: var(--active-color); color: var(--text-primary); }
.area-chip--active {
background: var(--active-color);
border-color: var(--active-color);
color: #101820;
}
/* ═══════════════════════════════════════════
SECCIÓN DESTACADOS
═══════════════════════════════════════════ */
.featured-section { display: flex; flex-direction: column; }
.featured-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.featured-card {
position: relative;
border-radius: 1.125rem;
overflow: hidden;
cursor: pointer;
aspect-ratio: 3/4;
background: var(--bg-secondary);
transition: transform 0.2s;
}
.featured-card:hover { transform: scale(0.98); }
.featured-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.featured-gradient {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.75) 40%, transparent 70%);
}
.featured-fav {
position: absolute;
top: 0.625rem;
right: 0.625rem;
z-index: 2;
}
.featured-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.875rem;
z-index: 2;
}
.featured-cat {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--active-color);
color: #101820;
font-size: 0.6875rem;
font-weight: 800;
padding: 0.15rem 0.5rem;
border-radius: 99px;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.375rem;
}
.featured-name {
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 900;
color: #ffffff;
line-height: 1.2;
}
.featured-area {
display: flex;
align-items: center;
gap: 0.2rem;
margin: 0;
font-size: 0.8rem;
color: rgba(255,255,255,0.75);
font-weight: 600;
}
/* ═══════════════════════════════════════════
GRID DE NEGOCIOS
═══════════════════════════════════════════ */
.all-section { display: flex; flex-direction: column; }
.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: transform 0.18s, border-color 0.18s;
}
.biz-card:hover {
transform: translateY(-4px);
border-color: var(--active-color);
}
.biz-img-wrap {
position: relative;
aspect-ratio: 16/10;
overflow: hidden;
}
.biz-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.biz-fav {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
.biz-cat-badge {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--active-color);
color: #101820;
font-size: 0.625rem;
font-weight: 800;
padding: 0.15rem 0.45rem;
border-radius: 99px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.biz-body { padding: 0.625rem 0.75rem; }
.biz-name {
margin: 0 0 0.25rem;
font-size: 0.875rem;
font-weight: 800;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.biz-area {
display: flex;
align-items: center;
gap: 0.2rem;
margin: 0;
font-size: 0.75rem;
color: var(--text-secondary);
font-weight: 600;
}
.biz-area-icon { font-size: 0.875rem; }
/* ═══════════════════════════════════════════
BARRA DE RESULTADOS
═══════════════════════════════════════════ */
.results-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: -0.5rem;
}
.results-count {
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-secondary);
}
.reset-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: none;
border: 1.5px solid var(--border-color);
border-radius: 99px;
padding: 0.3rem 0.75rem;
font-size: 0.75rem;
font-weight: 700;
font-family: inherit;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.18s;
}
.reset-btn .material-icons { font-size: 0.875rem; }
.reset-btn:hover { border-color: var(--active-color); color: var(--text-primary); }
/* ═══════════════════════════════════════════
TARJETA "MÁS"
═══════════════════════════════════════════ */
.more-card {
background: var(--bg-secondary);
border: 1.5px dashed var(--border-color);
border-radius: 1rem;
padding: 1.25rem;
text-align: center;
cursor: pointer;
transition: border-color 0.2s;
}
.more-card:hover { border-color: var(--active-color); }
.more-card-title {
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 800;
color: var(--text-primary);
}
.more-card-sub {
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
/* ═══════════════════════════════════════════
CTA Y VACÍO
═══════════════════════════════════════════ */
.cta-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--active-color);
color: #101820;
border: none;
border-radius: 99px;
font-size: 0.875rem;
font-weight: 800;
font-family: inherit;
cursor: pointer;
transition: transform 0.15s;
}
.cta-btn:active { transform: scale(0.97); }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1.25rem;
text-align: center;
}
.empty-icon {
font-size: 3.5rem;
color: var(--text-secondary);
opacity: 0.3;
margin-bottom: 1rem;
}
.empty-title {
font-size: 1.25rem;
font-weight: 900;
color: var(--text-primary);
margin: 0 0 0.5rem;
}
.empty-sub {
font-size: 0.9375rem;
color: var(--text-secondary);
margin: 0 0 1.5rem;
line-height: 1.5;
}
/* ═══════════════════════════════════════════
LOADING / SPINNER
═══════════════════════════════════════════ */
.state-center {
display: flex;
flex-direction: column;
align-items: 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); } }
/* ═══════════════════════════════════════════
TRANSICIÓN DE TARJETAS
═══════════════════════════════════════════ */
.fade-enter-active { transition: opacity 0.25s ease, transform 0.25s ease; }
.fade-enter-from { opacity: 0; transform: translateY(8px); }
/* ═══════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════ */
@media (min-width: 560px) {
.biz-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 360px) {
.featured-grid { grid-template-columns: 1fr; }
.featured-card { aspect-ratio: 4/3; }
}
</style>