feat: refactor discover page and detail view for activities

This commit is contained in:
2026-03-18 12:18:40 -05:00
parent 3991be8f23
commit 79fe4953c7
8 changed files with 1009 additions and 1062 deletions

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@ import { useCouponStore } from './stores/coupon'
import { supabase } from '@/supabase'
import { useGoogleMaps } from '@/composables/useGoogleMaps'
// useFavoritesStore ya importado arriba (línea 8)
import { useRegisterSW } from 'virtual:pwa-register/vue'
// Initialize theme store
const route = useRoute()
@ -28,6 +29,11 @@ const { loadMaps } = useGoogleMaps()
// Iniciar descarga asíncrona masiva de Google Maps al bootear VUE (Ahorra ~3 seg de espera en MapView)
loadMaps()
const { needRefresh, updateServiceWorker } = useRegisterSW()
const reloadPWA = () => {
updateServiceWorker(true)
}
const isLandingPage = computed(() => route.name === 'landing')
const isSplashScreen = computed(() => route.name === 'splash')
const isAuthScreen = computed(() => {
@ -184,6 +190,11 @@ onUnmounted(() => {
</script>
<template>
<div v-if="needRefresh" class="pwa-update-toast">
<p>¡Nueva versión disponible!</p>
<button @click="reloadPWA">Actualizar ahora</button>
</div>
<MainLayout v-if="!isSplashScreen && !isAuthScreen && !isLandingPage">
<RouterView />
</MainLayout>
@ -269,6 +280,49 @@ body {
flex-direction: column;
}
/* PWA Update Toast */
.pwa-update-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--active-color);
color: #000;
padding: 1rem 1.5rem;
border-radius: 1rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
box-shadow: 0 10px 25px rgba(254, 231, 21, 0.4);
font-weight: 600;
animation: slideInUp 0.3s ease-out forwards;
}
.pwa-update-toast p {
margin: 0;
font-size: 0.95rem;
}
.pwa-update-toast button {
background: #000;
color: #fff;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-weight: 700;
cursor: pointer;
transition: transform 0.2s;
}
.pwa-update-toast button:hover {
transform: scale(1.05);
}
@keyframes slideInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* Global Utilities */
.glass-effect {
background: var(--glass-bg);

View File

@ -216,41 +216,41 @@
"accessible": "Accesible"
},
"discover": {
"title": "¡Explora Chiriquí! 🌿",
"subtitle": "Descubre los mejores lugares cerca de ti",
"searchPlaceholder": "Buscar restaurantes, hoteles...",
"title": "Experiencias y Actividades 🌟",
"subtitle": "Descubre increíbles actividades en Chiriquí",
"searchPlaceholder": "Buscar tours, paseos, clases...",
"filterLabel": "Filtrar por área:",
"allAreas": "Todas",
"loading": "Cargando lugares...",
"error": "No se pudieron cargar los lugares. Revisa tu conexión.",
"empty": "No hay lugares aún",
"emptyDesc": "Pronto habrá negocios y lugares turísticos disponibles aquí.",
"loading": "Cargando actividades...",
"error": "No se pudieron cargar las actividades. Revisa tu conexión.",
"empty": "No hay actividades aún",
"emptyDesc": "Pronto habrá emocionantes actividades disponibles aquí.",
"noResults": "Sin resultados",
"noResultsDesc": "No encontramos lugares con ese filtro.",
"viewAll": "Ver todos los lugares",
"exploreMore": "Explorar Lugar",
"noResultsDesc": "No encontramos actividades con ese filtro.",
"viewAll": "Ver todas",
"exploreMore": "Ver Actividad",
"tourism": "Turismo",
"results": "{count} lugar | {count} lugares",
"results": "{count} actividad | {count} actividades",
"in": "en",
"lookingMore": "¿Buscas algo más?",
"exploreWithoutFilters": "Explora sin filtros para descubrir todo",
"sections": {
"byArea": "🗺️ Por Área",
"featured": "✨ Destacados",
"allPlaces": "🏙️ Todos los lugares"
"featured": "✨ Populares",
"allPlaces": "🎒 Todas las actividades"
},
"categories": {
"all": "Todas",
"restaurant": "Restaurante",
"hotel": "Hotel",
"cafe": "Café",
"commerce": "Comercio",
"tourism": "Turismo",
"drinks": "Bebidas"
"restaurant": "Gastronomía",
"hotel": "Hospedaje",
"cafe": "Cafeterías",
"commerce": "Compras",
"tourism": "Tours",
"drinks": "Vida Nocturna"
},
"auth": {
"title": Locales exclusivos!",
"message": "Para descubrir Chiriquí regístrate o accede. Encuentra los mejores rincones y ofertas directas."
"title": Actividades únicas!",
"message": "Para descubrir lo mejor de Chiriquí regístrate o accede. Encuentra tours y experiencias exclusivas."
}
},
"business": {
@ -277,8 +277,8 @@
"offlineDesc": "No pudimos cargar los datos. Revisa tu internet e intenta de nuevo.",
"website": "Sitio Web",
"gallery": "📸 Galería",
"about": "Sobre el lugar",
"contactUs": "Contáctanos",
"about": "Acerca de la actividad",
"contactUs": "Proveedor / Operador",
"offers": "🎁 Ofertas Disponibles",
"viewMap": "Ver en el Mapa",
"call": "Llamar",

View File

@ -2,7 +2,7 @@
import { supabase } from '@/supabase'
import type { Business } from '@/types'
const SELECT_FIELDS = 'id, name, address, phone, image_url, social_media, category, latitude, longitude, area, description, website, schedule, whatsapp, instagram, facebook, gallery_images, updated_at'
const SELECT_FIELDS = 'id, name, address, phone, image_url, social_media, category, latitude, longitude, area, description, website, supplier_name, supplier_description, schedule, whatsapp, instagram, facebook, gallery_images, updated_at'
export const businessService = {
/** Helper to upload file to supabase storage */

View File

@ -100,6 +100,8 @@ export interface Business {
area?: string | null
description?: string | null
website?: string | null
supplier_name?: string | null // Name of the business/operator
supplier_description?: string | null // Info about the business/operator
// Template/Mold fields
schedule?: string | null // "Lun-Sáb 8am-10pm"
whatsapp?: string | null // WhatsApp number

View File

@ -34,6 +34,8 @@ const businessForm = ref<Partial<Business>>({
whatsapp: '',
instagram: '',
facebook: '',
supplier_name: '',
supplier_description: '',
gallery_images: []
});
@ -118,6 +120,8 @@ async function saveBusiness() {
formData.append('whatsapp', businessForm.value.whatsapp || '');
formData.append('instagram', businessForm.value.instagram || '');
formData.append('facebook', businessForm.value.facebook || '');
formData.append('supplier_name', businessForm.value.supplier_name || '');
formData.append('supplier_description', businessForm.value.supplier_description || '');
if (businessForm.value.gallery_images?.length) {
formData.append('gallery_images', JSON.stringify(businessForm.value.gallery_images));
@ -170,7 +174,7 @@ const catEmoji = computed(() => CATEGORY_EMOJI[businessForm.value.category || ''
<button class="back-btn" @click="router.back()">
<span class="material-icons">arrow_back</span>
</button>
<h1>{{ isEditing ? 'Editar Negocio' : 'Nuevo Negocio' }}</h1>
<h1>{{ isEditing ? 'Editar Actividad' : 'Nueva Actividad' }}</h1>
</div>
<div class="admin-grid-layout">
@ -178,14 +182,14 @@ const catEmoji = computed(() => CATEGORY_EMOJI[businessForm.value.category || ''
<section class="form-panel nexus-glass">
<div class="section-title">
<span class="material-icons">storefront</span>
<h2>Datos del Negocio</h2>
<h2>Datos de la Actividad</h2>
</div>
<div class="nexus-form">
<div class="form-group grid-row">
<div class="input-box">
<label>Nombre del Negocio *</label>
<input v-model="businessForm.name" type="text" placeholder="Ej: Restaurante La Casona" required>
<label>Nombre de la Actividad *</label>
<input v-model="businessForm.name" type="text" placeholder="Ej: Tour Gastronómico / Restaurante La Casona" required>
</div>
<div class="input-box">
<label>Imagen de Portada</label>
@ -225,7 +229,7 @@ const catEmoji = computed(() => CATEGORY_EMOJI[businessForm.value.category || ''
</div>
<div class="form-group">
<label>Descripción del Lugar</label>
<label>Descripción de la Actividad</label>
<textarea
v-model="businessForm.description"
placeholder="Describe el ambiente, la especialidad, qué lo hace único..."
@ -250,8 +254,28 @@ const catEmoji = computed(() => CATEGORY_EMOJI[businessForm.value.category || ''
<input v-model="businessForm.address" type="text" placeholder="Ej: Calle Principal #123">
</div>
<div class="form-section-label mt-4">Redes y Contacto Directo</div>
<div class="form-section-label mt-4">Detalles del Proveedor / Operador</div>
<div class="form-group grid-row" style="margin-top: 12px;">
<div class="input-box">
<label>Nombre del Operador / Empresa</label>
<input v-model="businessForm.supplier_name" type="text" placeholder="Ej: Panama Tours S.A.">
</div>
<div class="input-box">
<label>Página Web</label>
<input v-model="businessForm.website" type="text" placeholder="https://">
</div>
</div>
<div class="form-group" style="margin-bottom: 12px;">
<label>Quiénes Somos (Información del Proveedor)</label>
<textarea
v-model="businessForm.supplier_description"
placeholder="Habla sobre la experiencia de la empresa que ofrece el servicio..."
rows="2"
class="nexus-textarea"
></textarea>
</div>
<div class="form-group grid-row">
<div class="input-box">
<label>WhatsApp (Sin +)</label>
@ -266,11 +290,6 @@ const catEmoji = computed(() => CATEGORY_EMOJI[businessForm.value.category || ''
</div>
</div>
<div class="form-group">
<label>Página Web</label>
<input v-model="businessForm.website" type="text" placeholder="https://">
</div>
<!-- GALERIA DE IMAGENES -->
<div class="form-section-label mt-4">📸 Galería de Imágenes</div>
<div class="form-group">
@ -309,7 +328,7 @@ const catEmoji = computed(() => CATEGORY_EMOJI[businessForm.value.category || ''
<button class="deploy-btn" :disabled="isLoading" @click="saveBusiness">
<span class="material-icons">{{ isLoading ? 'sync' : 'save' }}</span>
{{ isLoading ? 'GUARDANDO...' : 'GUARDAR NEGOCIO' }}
{{ isLoading ? 'GUARDANDO...' : 'GUARDAR ACTIVIDAD' }}
</button>
<p v-if="showMessage.text" :class="['message', showMessage.type]">{{ showMessage.text }}</p>
@ -334,7 +353,7 @@ const catEmoji = computed(() => CATEGORY_EMOJI[businessForm.value.category || ''
<div class="hero-body">
<span class="hero-cat">{{ catEmoji }} {{ businessForm.category }}</span>
<h1 class="hero-name">{{ businessForm.name || 'Nombre del Negocio' }}</h1>
<h1 class="hero-name">{{ businessForm.name || 'Nombre de la Actividad' }}</h1>
</div>
<div class="hero-rule"></div>
</section>
@ -348,22 +367,12 @@ const catEmoji = computed(() => CATEGORY_EMOJI[businessForm.value.category || ''
<section class="biz-section">
<div class="section-header">
<span class="section-accent"></span>
<h2 class="section-title">Sobre Nosotros</h2>
</div>
<p class="biz-desc">{{ businessForm.description || 'Aquí aparecerá la descripción del negocio. Un lugar espectacular para disfrutar de lo mejor en compañía de amigos y familiares.' }}</p>
<!-- Social links -->
<div class="social-links mt-4">
<button class="social-btn social-wa" v-if="businessForm.whatsapp">
<span class="material-icons">chat</span> WhatsApp
</button>
<button class="social-btn social-ig" v-if="businessForm.instagram">
<span class="material-icons">photo_camera</span> Instagram
</button>
<h2 class="section-title">Acerca de la Actividad</h2>
</div>
<p class="biz-desc">{{ businessForm.description || 'Aquí aparecerá la descripción de la actividad. Un lugar espectacular o una experiencia inolvidable.' }}</p>
</section>
<!-- Gallery carousel preview -->
<!-- Gallery carousel preview (now second) -->
<section v-if="galleryPreviews.length > 0" class="biz-section">
<div class="section-header">
<span class="section-accent"></span>
@ -380,6 +389,24 @@ const catEmoji = computed(() => CATEGORY_EMOJI[businessForm.value.category || ''
</div>
</section>
<section class="biz-section">
<div class="section-header">
<span class="section-accent"></span>
<h2 class="section-title">{{ businessForm.supplier_name || 'Proveedor / Operador' }}</h2>
</div>
<p class="biz-desc mb-4" v-if="businessForm.supplier_description">{{ businessForm.supplier_description }}</p>
<!-- Social links -->
<div class="social-links mt-4">
<button class="social-btn social-wa" v-if="businessForm.whatsapp">
<span class="material-icons">chat</span> WhatsApp
</button>
<button class="social-btn social-ig" v-if="businessForm.instagram">
<span class="material-icons">photo_camera</span> Instagram
</button>
</div>
</section>
<!-- Sticky CTA -->
<div class="cta-bar">
<button class="cta-map"><span class="material-icons">near_me</span> Ver en el Mapa</button>

View File

@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { businessService } from '@/services/businessService'
@ -24,7 +24,6 @@ const carouselIndex = ref(0)
const galleryImages = computed(() => {
if (!business.value) return []
const imgs: string[] = []
if (business.value.image_url) imgs.push(business.value.image_url)
if (business.value.gallery_images?.length) imgs.push(...business.value.gallery_images)
return imgs
})
@ -131,7 +130,7 @@ const quickInfoPills = computed(() => {
// Social networks available
const hasSocials = computed(() =>
!!(business.value?.whatsapp || business.value?.instagram || business.value?.facebook || business.value?.latitude)
!!(business.value?.whatsapp || business.value?.instagram || business.value?.facebook || business.value?.latitude || business.value?.supplier_name || business.value?.supplier_description)
)
</script>
@ -191,10 +190,18 @@ const hasSocials = computed(() =>
</section>
<!-- -->
<!-- SECTION 2 · QUICK INFO PILLS -->
<!-- SECTION 2 · ACTIVITY DETAILS (ABOUT) -->
<!-- -->
<div class="pills-scroll-wrap">
<div class="pills-row">
<section v-if="business.description || quickInfoPills.length" class="biz-section">
<div v-if="business.description" class="section-header" style="margin-bottom: 12px;">
<span class="section-accent"></span>
<h2 class="section-title">{{ t('business.about') }}</h2>
</div>
<p v-if="business.description" class="about-text" style="font-size: 1rem; margin-bottom: 24px;">{{ business.description }}</p>
<!-- QUICK INFO PILLS -->
<div v-if="quickInfoPills.length" class="pills-scroll-wrap" style="padding: 0;">
<div class="pills-row" style="flex-wrap: wrap; white-space: normal;">
<a
v-for="(pill, i) in quickInfoPills"
:key="i"
@ -208,6 +215,7 @@ const hasSocials = computed(() =>
</a>
</div>
</div>
</section>
<!-- -->
<!-- SECTION 3 · IMAGE CAROUSEL -->
@ -259,25 +267,20 @@ const hasSocials = computed(() =>
</div>
</section>
<!-- -->
<!-- SECTION 4 · ABOUT -->
<!-- -->
<section v-if="business.description" class="biz-section">
<div class="section-header">
<span class="section-accent"></span>
<h2 class="section-title">{{ t('business.about') }}</h2>
</div>
<p class="about-text">{{ business.description }}</p>
</section>
<!-- -->
<!-- SECTION 5 · SOCIALS & CONTACT -->
<!-- -->
<section v-if="hasSocials" class="biz-section">
<div class="section-header">
<span class="section-accent"></span>
<h2 class="section-title">{{ t('business.contactUs') }}</h2>
<h2 class="section-title">{{ business.supplier_name || t('business.contactUs') }}</h2>
</div>
<!-- QUIÉNES SOMOS DEL PROVEEDOR -->
<p v-if="business.supplier_description" class="about-text" style="font-size: 0.95rem; margin-bottom: 24px;">
{{ business.supplier_description }}
</p>
<div class="socials-grid">
<!-- WhatsApp -->

View File

@ -212,14 +212,14 @@ function resetFilters() {
:title="t('discover.auth.title')"
:message="t('discover.auth.message')"
>
<TransitionGroup v-if="filteredBusinesses.length > 0" name="fade" tag="div" class="biz-grid">
<div v-for="biz in filteredBusinesses" :key="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" class="biz-img"
<TransitionGroup v-if="filteredBusinesses.length > 0" name="fade" tag="div" class="activity-grid">
<div v-for="biz in filteredBusinesses" :key="biz.id" class="activity-card" @click="handleExplore(biz)">
<div class="card-img-wrap">
<img :src="getImageUrl(biz.image_url, 'business')" :alt="biz.name" loading="lazy" class="card-img"
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'business')" />
</div>
<div class="biz-body">
<p class="biz-name">{{ biz.name }}</p>
<div class="card-info">
<p class="card-title">{{ biz.name }}</p>
</div>
</div>
</TransitionGroup>
@ -241,13 +241,14 @@ function resetFilters() {
<!-- Destacados -->
<div v-if="featuredBusinesses.length > 0" class="featured-section">
<p class="section-label">{{ t('discover.sections.featured') }}</p>
<div class="featured-grid">
<div v-for="biz in featuredBusinesses" :key="biz.id" class="featured-card" @click="handleExplore(biz)">
<img :src="getImageUrl(biz.image_url, 'business')" :alt="biz.name" loading="lazy" class="featured-img"
<div class="activity-grid">
<div v-for="biz in featuredBusinesses" :key="biz.id" class="activity-card" @click="handleExplore(biz)">
<div class="card-img-wrap">
<img :src="getImageUrl(biz.image_url, 'business')" :alt="biz.name" loading="lazy" class="card-img"
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'business')" />
<div class="featured-gradient"></div>
<div class="featured-info">
<p class="featured-name">{{ biz.name }}</p>
</div>
<div class="card-info">
<p class="card-title">{{ biz.name }}</p>
</div>
</div>
</div>
@ -256,14 +257,14 @@ function resetFilters() {
<!-- Todos los lugares -->
<div v-if="gridBusinesses.length > 0" class="all-section">
<p class="section-label">{{ t('discover.sections.allPlaces') }}</p>
<TransitionGroup name="fade" tag="div" class="biz-grid">
<div v-for="biz in gridBusinesses" :key="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" class="biz-img"
<TransitionGroup name="fade" tag="div" class="activity-grid">
<div v-for="biz in gridBusinesses" :key="biz.id" class="activity-card" @click="handleExplore(biz)">
<div class="card-img-wrap">
<img :src="getImageUrl(biz.image_url, 'business')" :alt="biz.name" loading="lazy" class="card-img"
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'business')" />
</div>
<div class="biz-body">
<p class="biz-name">{{ biz.name }}</p>
<div class="card-info">
<p class="card-title">{{ biz.name }}</p>
</div>
</div>
</TransitionGroup>
@ -403,19 +404,62 @@ function resetFilters() {
/* CARDS */
.featured-section { margin-bottom: 1.5rem; }
.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: 16/9; background: var(--bg-secondary); }
.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.7) 0%, transparent 60%); }
.featured-info { position: absolute; bottom: 0; left: 0; right: 0; padding: 0.75rem; z-index: 2; text-align: center; }
.featured-name { margin: 0; font-size: 0.9375rem; font-weight: 900; color: #ffffff; text-shadow: 0 1px 4px rgba(0,0,0,0.5); }
.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; }
.biz-img-wrap { position: relative; aspect-ratio: 4/3; }
.biz-img { width: 100%; height: 100%; object-fit: cover; }
.biz-body { padding: 0.75rem; text-align: center; }
.biz-name { margin: 0; font-size: 0.875rem; font-weight: 800; color: var(--text-primary); line-height: 1.2; }
.activity-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.875rem;
}
.activity-card {
background: #0B132B;
border-radius: 1.25rem;
overflow: hidden;
cursor: pointer;
display: flex;
flex-direction: column;
box-shadow: 0 8px 20px rgba(0,0,0,0.15);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.activity-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 30px rgba(0,0,0,0.25);
}
.card-img-wrap {
position: relative;
aspect-ratio: 4/3;
width: 100%;
}
.card-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-info {
padding: 0.875rem;
text-align: left;
flex-grow: 1;
display: flex;
align-items: flex-start;
}
.card-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 800;
color: #ffffff;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
/* UTILS */
.results-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
@ -437,6 +481,6 @@ function resetFilters() {
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(10px); }
@media (max-width: 480px) {
.biz-grid, .featured-grid { grid-template-columns: repeat(2, 1fr); }
.activity-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>