feat: optimización de imágenes (WebP y AppImage), Lazy Load y LCP (Fase 1 y 2)
This commit is contained in:
@ -20,6 +20,7 @@
|
|||||||
<link rel="dns-prefetch" href="https://maps.googleapis.com">
|
<link rel="dns-prefetch" href="https://maps.googleapis.com">
|
||||||
<link rel="preconnect" href="https://wtxzmbqchntjymxszngc.supabase.co" crossorigin>
|
<link rel="preconnect" href="https://wtxzmbqchntjymxszngc.supabase.co" crossorigin>
|
||||||
<link rel="dns-prefetch" href="https://wtxzmbqchntjymxszngc.supabase.co">
|
<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">
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
||||||
<title>SIB - Sistema de Transporte</title>
|
<title>SIB - Sistema de Transporte</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@ -15,6 +15,7 @@
|
|||||||
"@googlemaps/js-api-loader": "^2.0.2",
|
"@googlemaps/js-api-loader": "^2.0.2",
|
||||||
"@supabase/supabase-js": "^2.97.0",
|
"@supabase/supabase-js": "^2.97.0",
|
||||||
"@tailwindcss/vite": "^4.2.0",
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^4.1.0",
|
"jspdf": "^4.1.0",
|
||||||
@ -4016,6 +4017,15 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.1",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
@ -8230,6 +8240,12 @@
|
|||||||
"base64-arraybuffer": "^1.0.2"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
"@googlemaps/js-api-loader": "^2.0.2",
|
"@googlemaps/js-api-loader": "^2.0.2",
|
||||||
"@supabase/supabase-js": "^2.97.0",
|
"@supabase/supabase-js": "^2.97.0",
|
||||||
"@tailwindcss/vite": "^4.2.0",
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^4.1.0",
|
"jspdf": "^4.1.0",
|
||||||
|
|||||||
88
frontend/src/components/AppImage.vue
Normal file
88
frontend/src/components/AppImage.vue
Normal 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>
|
||||||
@ -4,6 +4,22 @@ import { useRouter, useRoute } from 'vue-router';
|
|||||||
import { businessService } from '@/services/businessService';
|
import { businessService } from '@/services/businessService';
|
||||||
import type { Business } from '@/types';
|
import type { Business } from '@/types';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
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 router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -145,7 +161,8 @@ async function saveBusiness() {
|
|||||||
if (galleryFiles.value.length > 0) {
|
if (galleryFiles.value.length > 0) {
|
||||||
const uploadedUrls: string[] = [];
|
const uploadedUrls: string[] = [];
|
||||||
for (const gFile of galleryFiles.value) {
|
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);
|
uploadedUrls.push(url);
|
||||||
}
|
}
|
||||||
// Combinar las URL existentes ya serializadas + las nuevas subidas
|
// Combinar las URL existentes ya serializadas + las nuevas subidas
|
||||||
@ -154,7 +171,8 @@ async function saveBusiness() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selectedFile.value) {
|
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) {
|
if (isEditing.value && businessForm.value.id) {
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, computed } from 'vue'
|
import { onMounted, ref, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useCouponStore } from '@/stores/coupon'
|
import { useCouponStore } from '@/stores/coupon'
|
||||||
import type { Coupon } from '@/types'
|
import type { Coupon } from '@/types'
|
||||||
import FavoriteButton from '@/components/FavoriteButton.vue'
|
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 AuthGuard from '@/components/common/AuthGuard.vue'
|
||||||
import { analyticsService } from '@/services/analyticsService'
|
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)"
|
@click="openCoupon(coupon)"
|
||||||
>
|
>
|
||||||
<div class="offer-image-wrapper">
|
<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) }">
|
<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>
|
<span class="material-icons">schedule</span>
|
||||||
{{ coupon.title.toLowerCase().includes('mañana') ? t('coupons.tomorrow') : t('coupons.active') }}
|
{{ 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-scroll-body">
|
||||||
<div class="modal-hero-image">
|
<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>
|
||||||
|
|
||||||
<div class="modal-info-section">
|
<div class="modal-info-section">
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useFavoritesStore } from '@/stores/favorites'
|
import { useFavoritesStore } from '@/stores/favorites'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useTaxiStore } from '@/stores/taxi'
|
import { useTaxiStore } from '@/stores/taxi'
|
||||||
import { useI18n } from 'vue-i18n'
|
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'
|
import LoadingBranded from '@/components/common/LoadingBranded.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -42,10 +42,6 @@ function getTaxiPhone(id: string) {
|
|||||||
return taxi?.phone_number || ''
|
return taxi?.phone_number || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImageUrl(path?: string) {
|
|
||||||
return utilGetImageUrl(path, 'business')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeFavorite(event: Event, itemType: string, itemId: string) {
|
async function removeFavorite(event: Event, itemType: string, itemId: string) {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
await favoritesStore.removeFavorite(itemType, itemId)
|
await favoritesStore.removeFavorite(itemType, itemId)
|
||||||
@ -188,7 +184,7 @@ const hasVisibleItems = computed(() =>
|
|||||||
@click="navigateToItem(item)"
|
@click="navigateToItem(item)"
|
||||||
>
|
>
|
||||||
<div class="card-thumb card-thumb--img">
|
<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>
|
||||||
<div class="card-info">
|
<div class="card-info">
|
||||||
<p class="card-name">{{ item.item_name }}</p>
|
<p class="card-name">{{ item.item_name }}</p>
|
||||||
@ -224,7 +220,7 @@ const hasVisibleItems = computed(() =>
|
|||||||
@click="navigateToItem(item)"
|
@click="navigateToItem(item)"
|
||||||
>
|
>
|
||||||
<div class="card-thumb card-thumb--img">
|
<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>
|
||||||
<div class="card-info">
|
<div class="card-info">
|
||||||
<p class="card-name">{{ item.item_name }}</p>
|
<p class="card-name">{{ item.item_name }}</p>
|
||||||
@ -251,7 +247,7 @@ const hasVisibleItems = computed(() =>
|
|||||||
@click="navigateToItem(item)"
|
@click="navigateToItem(item)"
|
||||||
>
|
>
|
||||||
<div class="biz-img">
|
<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)">
|
<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>
|
<span class="material-icons">favorite</span>
|
||||||
</button>
|
</button>
|
||||||
@ -279,7 +275,7 @@ const hasVisibleItems = computed(() =>
|
|||||||
@click="navigateToItem(item)"
|
@click="navigateToItem(item)"
|
||||||
>
|
>
|
||||||
<div class="card-thumb card-thumb--event">
|
<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>
|
<span v-else class="material-icons">local_activity</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-info">
|
<div class="card-info">
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
<div class="hero__glow hero__glow--1"></div>
|
<div class="hero__glow hero__glow--1"></div>
|
||||||
<div class="hero__glow hero__glow--2"></div>
|
<div class="hero__glow hero__glow--2"></div>
|
||||||
</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__overlay"></div>
|
||||||
|
|
||||||
<div class="hero__content">
|
<div class="hero__content">
|
||||||
|
|||||||
Reference in New Issue
Block a user