feat: optimización de imágenes (WebP y AppImage), Lazy Load y LCP (Fase 1 y 2)
This commit is contained in:
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 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) {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
Reference in New Issue
Block a user