feat: optimización de imágenes (WebP y AppImage), Lazy Load y LCP (Fase 1 y 2)

This commit is contained in:
2026-03-21 15:08:05 -05:00
parent a89bf59bdf
commit 64c3bbb1d7
8 changed files with 137 additions and 20 deletions

View File

@ -20,6 +20,7 @@
<link rel="dns-prefetch" href="https://maps.googleapis.com">
<link rel="preconnect" href="https://wtxzmbqchntjymxszngc.supabase.co" crossorigin>
<link rel="dns-prefetch" href="https://wtxzmbqchntjymxszngc.supabase.co">
<link rel="preload" as="image" href="/landing-bg-bus.png" fetchpriority="high">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
<title>SIB - Sistema de Transporte</title>
</head>

View File

@ -15,6 +15,7 @@
"@googlemaps/js-api-loader": "^2.0.2",
"@supabase/supabase-js": "^2.97.0",
"@tailwindcss/vite": "^4.2.0",
"browser-image-compression": "^2.0.2",
"chart.js": "^4.5.1",
"html2canvas": "^1.4.1",
"jspdf": "^4.1.0",
@ -4016,6 +4017,15 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/browser-image-compression": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz",
"integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==",
"license": "MIT",
"dependencies": {
"uzip": "0.20201231.0"
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@ -8230,6 +8240,12 @@
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uzip": {
"version": "0.20201231.0",
"resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz",
"integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==",
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

@ -16,6 +16,7 @@
"@googlemaps/js-api-loader": "^2.0.2",
"@supabase/supabase-js": "^2.97.0",
"@tailwindcss/vite": "^4.2.0",
"browser-image-compression": "^2.0.2",
"chart.js": "^4.5.1",
"html2canvas": "^1.4.1",
"jspdf": "^4.1.0",

View File

@ -0,0 +1,88 @@
<template>
<div class="app-image-wrapper" :class="{ 'is-loading': !isLoaded, [wrapperClass]: !!wrapperClass }">
<img
:src="resolvedSrc"
:alt="alt"
:loading="priority ? 'eager' : 'lazy'"
:fetchpriority="priority ? 'high' : 'auto'"
decoding="async"
@load="handleLoad"
@error="handleError"
class="app-image-content"
:class="{ 'is-loaded': isLoaded, [imgClass]: !!imgClass }"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { getImageUrl } from '@/utils/imageUrl'
const props = withDefaults(defineProps<{
src?: string | null;
alt?: string;
type?: 'taxi' | 'shuttle' | 'business' | 'coupon';
priority?: boolean;
errorFallback?: string;
wrapperClass?: string;
imgClass?: string;
}>(), {
priority: false,
wrapperClass: '',
imgClass: ''
});
const isLoaded = ref(false)
const isError = ref(false)
const resolvedSrc = computed(() => {
if (isError.value && props.errorFallback) return props.errorFallback
return getImageUrl(props.src, props.type)
})
function handleLoad() {
isLoaded.value = true
}
function handleError(e: Event) {
isError.value = true
isLoaded.value = true
}
</script>
<style scoped>
.app-image-wrapper {
position: relative;
overflow: hidden;
display: inline-flex;
justify-content: center;
align-items: center;
background-color: transparent;
}
.app-image-wrapper.is-loading {
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: pulse 1.5s infinite;
}
.app-image-content {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
.app-image-content.is-loaded {
opacity: 1;
}
@media (prefers-color-scheme: dark) {
.app-image-wrapper.is-loading {
background: linear-gradient(90deg, #1f2937 25%, #374151 50%, #1f2937 75%);
}
}
@keyframes pulse {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>

View File

@ -4,6 +4,22 @@ import { useRouter, useRoute } from 'vue-router';
import { businessService } from '@/services/businessService';
import type { Business } from '@/types';
import { useAuthStore } from '@/stores/auth';
import imageCompression from 'browser-image-compression';
async function compressImage(file: File) {
const options = {
maxSizeMB: 0.5,
maxWidthOrHeight: 1200,
useWebWorker: true,
fileType: 'image/webp'
};
try {
return await imageCompression(file, options);
} catch (err) {
console.error('Error compressing image', err);
return file; // fallback to original
}
}
const router = useRouter();
const route = useRoute();
@ -145,7 +161,8 @@ async function saveBusiness() {
if (galleryFiles.value.length > 0) {
const uploadedUrls: string[] = [];
for (const gFile of galleryFiles.value) {
const url = await businessService.uploadImage(gFile);
const compressedFile = await compressImage(gFile) as File;
const url = await businessService.uploadImage(compressedFile);
uploadedUrls.push(url);
}
// Combinar las URL existentes ya serializadas + las nuevas subidas
@ -154,7 +171,8 @@ async function saveBusiness() {
}
if (selectedFile.value) {
formData.append('image', selectedFile.value);
const compressedMain = await compressImage(selectedFile.value) as File;
formData.append('image', compressedMain);
}
if (isEditing.value && businessForm.value.id) {

View File

@ -1,10 +1,10 @@
<script setup lang="ts">
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCouponStore } from '@/stores/coupon'
import type { Coupon } from '@/types'
import FavoriteButton from '@/components/FavoriteButton.vue'
import { getImageUrl as utilGetImageUrl } from '@/utils/imageUrl'
import AppImage from '@/components/AppImage.vue'
import AuthGuard from '@/components/common/AuthGuard.vue'
import { analyticsService } from '@/services/analyticsService'
@ -39,9 +39,6 @@ const filteredCoupons = computed(() => {
})
})
function getImageUrl(path: string | null | undefined) {
return utilGetImageUrl(path, 'coupon')
}
@ -144,7 +141,7 @@ function getCategoryIcon(category?: string | null) {
@click="openCoupon(coupon)"
>
<div class="offer-image-wrapper">
<img :src="getImageUrl(coupon.image_url)" :alt="coupon.title" loading="lazy" decoding="async" class="offer-img">
<AppImage :src="coupon.image_url" :alt="coupon.title" type="coupon" imgClass="offer-img" />
<div class="status-badge" :class="{ 'tmr': coupon.title.toLowerCase().includes('mañana') || (coupon.description?.toLowerCase().includes('mañana') ?? false) }">
<span class="material-icons">schedule</span>
{{ coupon.title.toLowerCase().includes('mañana') ? t('coupons.tomorrow') : t('coupons.active') }}
@ -201,7 +198,7 @@ function getCategoryIcon(category?: string | null) {
<div class="modal-scroll-body">
<div class="modal-hero-image">
<img :src="getImageUrl(selectedCoupon.image_url)" alt="Header Image">
<AppImage :src="selectedCoupon.image_url" type="coupon" alt="Header Image" />
</div>
<div class="modal-info-section">

View File

@ -1,11 +1,11 @@
<script setup lang="ts">
<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 AppImage from '@/components/AppImage.vue'
import LoadingBranded from '@/components/common/LoadingBranded.vue'
const router = useRouter()
@ -42,10 +42,6 @@ function getTaxiPhone(id: string) {
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)
@ -188,7 +184,7 @@ const hasVisibleItems = computed(() =>
@click="navigateToItem(item)"
>
<div class="card-thumb card-thumb--img">
<img :src="getImageUrl(item.item_image)" :alt="item.item_name" />
<AppImage :src="item.item_image" type="taxi" :alt="item.item_name" />
</div>
<div class="card-info">
<p class="card-name">{{ item.item_name }}</p>
@ -224,7 +220,7 @@ const hasVisibleItems = computed(() =>
@click="navigateToItem(item)"
>
<div class="card-thumb card-thumb--img">
<img :src="getImageUrl(item.item_image)" :alt="item.item_name" />
<AppImage :src="item.item_image" type="shuttle" :alt="item.item_name" />
</div>
<div class="card-info">
<p class="card-name">{{ item.item_name }}</p>
@ -251,7 +247,7 @@ const hasVisibleItems = computed(() =>
@click="navigateToItem(item)"
>
<div class="biz-img">
<img :src="getImageUrl(item.item_image)" :alt="item.item_name" />
<AppImage :src="item.item_image" type="business" :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>
@ -279,7 +275,7 @@ const hasVisibleItems = computed(() =>
@click="navigateToItem(item)"
>
<div class="card-thumb card-thumb--event">
<img v-if="item.item_image" :src="getImageUrl(item.item_image)" class="thumb-img" />
<AppImage v-if="item.item_image" :src="item.item_image" type="coupon" class="thumb-img" />
<span v-else class="material-icons">local_activity</span>
</div>
<div class="card-info">

View File

@ -25,7 +25,7 @@
<div class="hero__glow hero__glow--1"></div>
<div class="hero__glow hero__glow--2"></div>
</div>
<img src="/landing-bg-bus.png" alt="Bus in background" class="hero__img" />
<img src="/landing-bg-bus.png" fetchpriority="high" alt="Bus in background" class="hero__img" />
<div class="hero__overlay"></div>
<div class="hero__content">