Perf: Fase 3 PWA Caché optimizado y Scroll Infinito progresivo en Vistas
This commit is contained in:
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import { businessService } from '@/services/businessService'
|
import { businessService } from '@/services/businessService'
|
||||||
import type { Business } from '@/types'
|
import type { Business } from '@/types'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@ -18,6 +18,11 @@ const searchQuery = ref('')
|
|||||||
const selectedCategory = ref('Todas')
|
const selectedCategory = ref('Todas')
|
||||||
const selectedArea = ref('Todas')
|
const selectedArea = ref('Todas')
|
||||||
|
|
||||||
|
// Infinite Scroll
|
||||||
|
const displayLimit = ref(12)
|
||||||
|
const observerTarget = ref<HTMLElement | null>(null)
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
// ── Categorías con emoji e ícono material
|
// ── Categorías con emoji e ícono material
|
||||||
const CATEGORY_META: Record<string, { emoji: string; icon: string; key: string }> = {
|
const CATEGORY_META: Record<string, { emoji: string; icon: string; key: string }> = {
|
||||||
'Todas': { emoji: '✨', icon: 'apps', key: 'discover.categories.all' },
|
'Todas': { emoji: '✨', icon: 'apps', key: 'discover.categories.all' },
|
||||||
@ -63,20 +68,39 @@ onMounted(() => {
|
|||||||
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'Discover' })
|
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'Discover' })
|
||||||
loadBusinesses()
|
loadBusinesses()
|
||||||
window.addEventListener('app-refocus', handleRefocus)
|
window.addEventListener('app-refocus', handleRefocus)
|
||||||
|
|
||||||
|
// Infinite Scroll Observer
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) {
|
||||||
|
displayLimit.value += 12
|
||||||
|
}
|
||||||
|
}, { rootMargin: '400px' })
|
||||||
|
|
||||||
|
if (observerTarget.value && observer) {
|
||||||
|
observer.observe(observerTarget.value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('app-refocus', handleRefocus)
|
window.removeEventListener('app-refocus', handleRefocus)
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset display limit when filters change
|
||||||
|
watch([selectedCategory, selectedArea, searchQuery], () => {
|
||||||
|
displayLimit.value = 12
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Computados
|
// ── Computados
|
||||||
const categories = computed<string[]>(() => {
|
const categories = computed<string[]>(() => {
|
||||||
const cats = new Set(businesses.value.map(b => b.category).filter(Boolean) as string[])
|
const cats = new Set(businesses.value.map(b => b.category || '').filter(Boolean))
|
||||||
return ['Todas', ...Array.from(cats)]
|
return ['Todas', ...Array.from(cats)]
|
||||||
})
|
})
|
||||||
|
|
||||||
const areas = computed<string[]>(() => {
|
const areas = computed<string[]>(() => {
|
||||||
const ars = new Set(businesses.value.map(b => b.area).filter(Boolean) as string[])
|
const ars = new Set(businesses.value.map(b => b.area || '').filter(Boolean))
|
||||||
const sorted = Array.from(ars).sort()
|
const sorted = Array.from(ars).sort()
|
||||||
return ['Todas', ...sorted]
|
return ['Todas', ...sorted]
|
||||||
})
|
})
|
||||||
@ -102,8 +126,8 @@ const featuredBusinesses = computed(() =>
|
|||||||
|
|
||||||
const gridBusinesses = computed(() => {
|
const gridBusinesses = computed(() => {
|
||||||
const hasFilter = selectedCategory.value !== 'Todas' || selectedArea.value !== 'Todas' || searchQuery.value.trim()
|
const hasFilter = selectedCategory.value !== 'Todas' || selectedArea.value !== 'Todas' || searchQuery.value.trim()
|
||||||
if (hasFilter) return filteredBusinesses.value
|
if (hasFilter) return filteredBusinesses.value.slice(0, displayLimit.value)
|
||||||
return businesses.value.slice(2)
|
return businesses.value.slice(2, Math.max(2, displayLimit.value + 2))
|
||||||
})
|
})
|
||||||
|
|
||||||
const isFiltering = computed(() =>
|
const isFiltering = computed(() =>
|
||||||
@ -212,8 +236,9 @@ function resetFilters() {
|
|||||||
:title="t('discover.auth.title')"
|
:title="t('discover.auth.title')"
|
||||||
:message="t('discover.auth.message')"
|
:message="t('discover.auth.message')"
|
||||||
>
|
>
|
||||||
<TransitionGroup v-if="filteredBusinesses.length > 0" name="fade" tag="div" class="activity-grid">
|
<div v-if="filteredBusinesses.length > 0">
|
||||||
<div v-for="biz in filteredBusinesses" :key="biz.id" class="activity-card" @click="handleExplore(biz)">
|
<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">
|
<div class="card-img-wrap">
|
||||||
<AppImage :src="biz.image_url" type="business" :alt="biz.name" imgClass="card-img" />
|
<AppImage :src="biz.image_url" type="business" :alt="biz.name" imgClass="card-img" />
|
||||||
</div>
|
</div>
|
||||||
@ -223,6 +248,10 @@ function resetFilters() {
|
|||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
|
|
||||||
|
<!-- Infinite Scroll Trigger -->
|
||||||
|
<div ref="observerTarget" class="h-10 w-full mt-4"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<span class="material-icons empty-icon">search_off</span>
|
<span class="material-icons empty-icon">search_off</span>
|
||||||
<h2 class="empty-title">{{ t('discover.noResults') }}</h2>
|
<h2 class="empty-title">{{ t('discover.noResults') }}</h2>
|
||||||
@ -265,6 +294,9 @@ function resetFilters() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
|
|
||||||
|
<!-- Infinite Scroll Trigger -->
|
||||||
|
<div ref="observerTarget" class="h-10 w-full mt-4"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="businesses.length === 0" class="empty-state">
|
<div v-if="businesses.length === 0" class="empty-state">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useTaxiStore } from '@/stores/taxi'
|
import { useTaxiStore } from '@/stores/taxi'
|
||||||
import { analyticsService } from '@/services/analyticsService'
|
import { analyticsService } from '@/services/analyticsService'
|
||||||
@ -19,6 +19,11 @@ const onlyEnglish = ref(false)
|
|||||||
const corregimientos = ['all', 'Boquete', 'David - Boquete', 'Boquete - David', 'Aeropuerto - Boquete']
|
const corregimientos = ['all', 'Boquete', 'David - Boquete', 'Boquete - David', 'Aeropuerto - Boquete']
|
||||||
const shifts = ['all', 'dia', 'tarde', 'noche']
|
const shifts = ['all', 'dia', 'tarde', 'noche']
|
||||||
|
|
||||||
|
// Infinite Scroll
|
||||||
|
const displayLimit = ref(12)
|
||||||
|
const observerTarget = ref<HTMLElement | null>(null)
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
taxiStore.loadTaxis()
|
taxiStore.loadTaxis()
|
||||||
}
|
}
|
||||||
@ -31,6 +36,18 @@ function handleRefocus() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'TaxisLocales' })
|
analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'TaxisLocales' })
|
||||||
window.addEventListener('app-refocus', handleRefocus)
|
window.addEventListener('app-refocus', handleRefocus)
|
||||||
|
|
||||||
|
// Infinite Scroll Observer
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) {
|
||||||
|
displayLimit.value += 12
|
||||||
|
}
|
||||||
|
}, { rootMargin: '400px' })
|
||||||
|
|
||||||
|
if (observerTarget.value && observer) {
|
||||||
|
observer.observe(observerTarget.value)
|
||||||
|
}
|
||||||
|
|
||||||
if(taxiStore.taxis.length === 0) {
|
if(taxiStore.taxis.length === 0) {
|
||||||
await fetchData()
|
await fetchData()
|
||||||
}
|
}
|
||||||
@ -38,6 +55,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('app-refocus', handleRefocus)
|
window.removeEventListener('app-refocus', handleRefocus)
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([selectedZone, selectedShift, onlyEnglish], () => {
|
||||||
|
displayLimit.value = 12
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredTaxis = computed(() => {
|
const filteredTaxis = computed(() => {
|
||||||
@ -50,6 +74,10 @@ const filteredTaxis = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const visibleTaxis = computed(() => {
|
||||||
|
return filteredTaxis.value.slice(0, displayLimit.value)
|
||||||
|
})
|
||||||
|
|
||||||
const isOnline = (taxi: Taxi) => {
|
const isOnline = (taxi: Taxi) => {
|
||||||
if (!taxi.shifts) return false
|
if (!taxi.shifts) return false
|
||||||
return taxi.shifts.includes('dia') || taxi.shifts.includes('tarde')
|
return taxi.shifts.includes('dia') || taxi.shifts.includes('tarde')
|
||||||
@ -139,7 +167,7 @@ function getShiftLabel(shift: string) {
|
|||||||
:message="t('shuttle.auth.message')"
|
:message="t('shuttle.auth.message')"
|
||||||
>
|
>
|
||||||
<div class="taxis-grid">
|
<div class="taxis-grid">
|
||||||
<div v-for="taxi in filteredTaxis" :key="taxi.id" v-memo="[taxi.id]" class="taxi-card-new glass-effect">
|
<div v-for="taxi in visibleTaxis" :key="taxi.id" v-memo="[taxi.id]" class="taxi-card-new glass-effect">
|
||||||
<div class="card-top">
|
<div class="card-top">
|
||||||
<div class="driver-avatar-wrap">
|
<div class="driver-avatar-wrap">
|
||||||
<div class="driver-avatar">
|
<div class="driver-avatar">
|
||||||
@ -201,6 +229,9 @@ function getShiftLabel(shift: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Infinite Scroll Trigger -->
|
||||||
|
<div ref="observerTarget" class="h-10 w-full mt-4"></div>
|
||||||
|
|
||||||
<div v-if="filteredTaxis.length === 0" class="empty-state">
|
<div v-if="filteredTaxis.length === 0" class="empty-state">
|
||||||
<span class="material-icons">no_accounts</span>
|
<span class="material-icons">no_accounts</span>
|
||||||
<p>{{ t('taxi.noTaxisAvailable') }}</p>
|
<p>{{ t('taxi.noTaxisAvailable') }}</p>
|
||||||
|
|||||||
@ -85,7 +85,10 @@ export default defineConfig(() => {
|
|||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'supabase-images-cache',
|
cacheName: 'supabase-images-cache',
|
||||||
expiration: { maxEntries: 100, maxAgeSeconds: 2592000 }
|
expiration: { maxEntries: 200, maxAgeSeconds: 2592000 },
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user