feat(carousel): enhance image loading strategy and improve product display in carousel
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
class="store-home__main-carousel"
|
class="store-home__main-carousel"
|
||||||
[images]="mainCarouselImages"
|
[images]="mainCarouselImages"
|
||||||
ariaLabel="Imágenes destacadas de la tienda"
|
ariaLabel="Imágenes destacadas de la tienda"
|
||||||
|
(firstImageReady)="mainCarouselReady.set(true)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
[groupLayout]="group.group_layout"
|
[groupLayout]="group.group_layout"
|
||||||
[items]="group.items"
|
[items]="group.items"
|
||||||
[loading]="isGroupLoading(group.id)"
|
[loading]="isGroupLoading(group.id)"
|
||||||
|
[loadImages]="!hasMainCarouselImages() || mainCarouselReady()"
|
||||||
(buy)="onBuyProduct($event)"
|
(buy)="onBuyProduct($event)"
|
||||||
(addToCart)="onAddToCart($event)"
|
(addToCart)="onAddToCart($event)"
|
||||||
(pageChange)="onPageChange(group.id, $event)"
|
(pageChange)="onPageChange(group.id, $event)"
|
||||||
|
|||||||
@@ -176,7 +176,14 @@ describe('StoreHomePageComponent', () => {
|
|||||||
expect(element.firstElementChild).toBe(carousel);
|
expect(element.firstElementChild).toBe(carousel);
|
||||||
expect(images).toHaveLength(2);
|
expect(images).toHaveLength(2);
|
||||||
expect(images?.[0].getAttribute('src')).toBe(mainCarouselImages[0]);
|
expect(images?.[0].getAttribute('src')).toBe(mainCarouselImages[0]);
|
||||||
|
expect(images?.[1].getAttribute('src')).toBeNull();
|
||||||
|
expect(element.querySelectorAll('app-product-column-with-image img')).toHaveLength(0);
|
||||||
|
|
||||||
|
images?.[0].dispatchEvent(new Event('load'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(images?.[1].getAttribute('src')).toBe(mainCarouselImages[1]);
|
expect(images?.[1].getAttribute('src')).toBe(mainCarouselImages[1]);
|
||||||
|
expect(element.querySelectorAll('app-product-column-with-image img')).toHaveLength(1);
|
||||||
|
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
|
computed,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -53,6 +54,10 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
|
|||||||
protected readonly loading = signal(false);
|
protected readonly loading = signal(false);
|
||||||
protected readonly loadingGroupIds = signal<ReadonlySet<number>>(new Set());
|
protected readonly loadingGroupIds = signal<ReadonlySet<number>>(new Set());
|
||||||
protected readonly error = signal<string | null>(null);
|
protected readonly error = signal<string | null>(null);
|
||||||
|
protected readonly mainCarouselReady = signal(false);
|
||||||
|
protected readonly hasMainCarouselImages = computed(
|
||||||
|
() => (this.tenant()?.main_carousel_images?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
|
||||||
private catalogRequestSubscription: Subscription | null = null;
|
private catalogRequestSubscription: Subscription | null = null;
|
||||||
private readonly groupRequestSubscriptions = new Map<number, Subscription>();
|
private readonly groupRequestSubscriptions = new Map<number, Subscription>();
|
||||||
|
|||||||
@@ -15,11 +15,14 @@
|
|||||||
<img
|
<img
|
||||||
class="main-carousel__image"
|
class="main-carousel__image"
|
||||||
[class.main-carousel__image--active]="index === activeIndex()"
|
[class.main-carousel__image--active]="index === activeIndex()"
|
||||||
[src]="image"
|
[attr.src]="imageSource(image, index)"
|
||||||
[alt]="imageAlt(index)"
|
[alt]="imageAlt(index)"
|
||||||
[attr.aria-hidden]="index === activeIndex() ? null : 'true'"
|
[attr.aria-hidden]="index === activeIndex() ? null : 'true'"
|
||||||
[attr.loading]="index === 0 ? 'eager' : 'lazy'"
|
loading="eager"
|
||||||
|
[attr.fetchpriority]="index === activeIndex() ? 'high' : 'low'"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
(load)="handleImageLoad(index)"
|
||||||
|
(error)="handleImageError(index)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,29 @@ describe('MainCarouselComponent', () => {
|
|||||||
expect(images[0].classList).toContain('main-carousel__image--active');
|
expect(images[0].classList).toContain('main-carousel__image--active');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carga las imágenes secuencialmente y prioriza la imagen visible', () => {
|
||||||
|
const images = fixture.nativeElement.querySelectorAll(
|
||||||
|
'.main-carousel__image',
|
||||||
|
) as NodeListOf<HTMLImageElement>;
|
||||||
|
|
||||||
|
expect(images[0].getAttribute('src')).toBe('/images/one.webp');
|
||||||
|
expect(images[0].getAttribute('fetchpriority')).toBe('high');
|
||||||
|
expect(images[1].getAttribute('src')).toBeNull();
|
||||||
|
expect(images[2].getAttribute('src')).toBeNull();
|
||||||
|
|
||||||
|
images[0].dispatchEvent(new Event('load'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(images[1].getAttribute('src')).toBe('/images/two.webp');
|
||||||
|
expect(images[1].getAttribute('fetchpriority')).toBe('low');
|
||||||
|
expect(images[2].getAttribute('src')).toBeNull();
|
||||||
|
|
||||||
|
images[1].dispatchEvent(new Event('load'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(images[2].getAttribute('src')).toBe('/images/three.webp');
|
||||||
|
});
|
||||||
|
|
||||||
it('avanza automáticamente entre las imágenes', () => {
|
it('avanza automáticamente entre las imágenes', () => {
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -48,10 +71,18 @@ describe('MainCarouselComponent', () => {
|
|||||||
try {
|
try {
|
||||||
fixture = TestBed.createComponent(TestHostComponent);
|
fixture = TestBed.createComponent(TestHostComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
const images = fixture.nativeElement.querySelectorAll(
|
||||||
|
'.main-carousel__image',
|
||||||
|
) as NodeListOf<HTMLImageElement>;
|
||||||
|
|
||||||
|
images[0].dispatchEvent(new Event('load'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
images[1].dispatchEvent(new Event('load'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
vi.advanceTimersByTime(1000);
|
vi.advanceTimersByTime(1000);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const images = fixture.nativeElement.querySelectorAll('.main-carousel__image');
|
|
||||||
expect(images[1].classList).toContain('main-carousel__image--active');
|
expect(images[1].classList).toContain('main-carousel__image--active');
|
||||||
} finally {
|
} finally {
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
PLATFORM_ID,
|
PLATFORM_ID,
|
||||||
|
WritableSignal,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
|
output,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
@@ -20,11 +22,15 @@ export class MainCarouselComponent {
|
|||||||
private readonly platformId = inject(PLATFORM_ID);
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
private readonly requestedIndex = signal(0);
|
private readonly requestedIndex = signal(0);
|
||||||
private readonly paused = signal(false);
|
private readonly paused = signal(false);
|
||||||
|
private readonly loadedIndexes = signal<ReadonlySet<number>>(new Set());
|
||||||
|
private readonly settledIndexes = signal<ReadonlySet<number>>(new Set());
|
||||||
|
private firstImageReadyEmitted = false;
|
||||||
|
|
||||||
readonly images = input.required<readonly string[]>();
|
readonly images = input.required<readonly string[]>();
|
||||||
readonly imageAlts = input<readonly string[]>([]);
|
readonly imageAlts = input<readonly string[]>([]);
|
||||||
readonly autoSlideInterval = input(5000);
|
readonly autoSlideInterval = input(5000);
|
||||||
readonly ariaLabel = input('Imágenes destacadas');
|
readonly ariaLabel = input('Imágenes destacadas');
|
||||||
|
readonly firstImageReady = output<void>();
|
||||||
|
|
||||||
protected readonly activeIndex = computed(() => {
|
protected readonly activeIndex = computed(() => {
|
||||||
const imageCount = this.images().length;
|
const imageCount = this.images().length;
|
||||||
@@ -32,8 +38,27 @@ export class MainCarouselComponent {
|
|||||||
return imageCount ? this.requestedIndex() % imageCount : 0;
|
return imageCount ? this.requestedIndex() % imageCount : 0;
|
||||||
});
|
});
|
||||||
protected readonly hasMultipleImages = computed(() => this.images().length > 1);
|
protected readonly hasMultipleImages = computed(() => this.images().length > 1);
|
||||||
|
protected readonly sequentialLoadIndex = computed(() => {
|
||||||
|
const settledIndexes = this.settledIndexes();
|
||||||
|
|
||||||
|
for (let index = 0; index < this.images().length; index += 1) {
|
||||||
|
if (!settledIndexes.has(index)) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
this.images();
|
||||||
|
this.requestedIndex.set(0);
|
||||||
|
this.loadedIndexes.set(new Set());
|
||||||
|
this.settledIndexes.set(new Set());
|
||||||
|
this.firstImageReadyEmitted = false;
|
||||||
|
});
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const imageCount = this.images().length;
|
const imageCount = this.images().length;
|
||||||
const interval = Math.max(this.autoSlideInterval(), 1000);
|
const interval = Math.max(this.autoSlideInterval(), 1000);
|
||||||
@@ -44,7 +69,11 @@ export class MainCarouselComponent {
|
|||||||
|
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
if (!this.paused()) {
|
if (!this.paused()) {
|
||||||
this.requestedIndex.update((index) => (index + 1) % imageCount);
|
this.requestedIndex.update((index) => {
|
||||||
|
const nextIndex = (index + 1) % imageCount;
|
||||||
|
|
||||||
|
return this.loadedIndexes().has(nextIndex) ? nextIndex : index;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, interval);
|
}, interval);
|
||||||
|
|
||||||
@@ -62,6 +91,27 @@ export class MainCarouselComponent {
|
|||||||
this.paused.set(paused);
|
this.paused.set(paused);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected imageSource(image: string, index: number): string | null {
|
||||||
|
const shouldLoad =
|
||||||
|
this.loadedIndexes().has(index) ||
|
||||||
|
index === this.sequentialLoadIndex() ||
|
||||||
|
index === this.activeIndex();
|
||||||
|
|
||||||
|
return shouldLoad ? image : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected handleImageLoad(index: number): void {
|
||||||
|
this.updateIndexSet(this.loadedIndexes, index, true);
|
||||||
|
this.updateIndexSet(this.settledIndexes, index, true);
|
||||||
|
this.notifyFirstImageReady(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected handleImageError(index: number): void {
|
||||||
|
this.updateIndexSet(this.loadedIndexes, index, false);
|
||||||
|
this.updateIndexSet(this.settledIndexes, index, true);
|
||||||
|
this.notifyFirstImageReady(index);
|
||||||
|
}
|
||||||
|
|
||||||
protected handleKeydown(event: KeyboardEvent): void {
|
protected handleKeydown(event: KeyboardEvent): void {
|
||||||
if (!this.hasMultipleImages()) {
|
if (!this.hasMultipleImages()) {
|
||||||
return;
|
return;
|
||||||
@@ -83,4 +133,26 @@ export class MainCarouselComponent {
|
|||||||
protected imageAlt(index: number): string {
|
protected imageAlt(index: number): string {
|
||||||
return this.imageAlts()[index] || `Imagen destacada ${index + 1}`;
|
return this.imageAlts()[index] || `Imagen destacada ${index + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateIndexSet(
|
||||||
|
target: WritableSignal<ReadonlySet<number>>,
|
||||||
|
index: number,
|
||||||
|
present: boolean,
|
||||||
|
): void {
|
||||||
|
target.update((current) => {
|
||||||
|
const updated = new Set(current);
|
||||||
|
present ? updated.add(index) : updated.delete(index);
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyFirstImageReady(index: number): void {
|
||||||
|
if (index !== 0 || this.firstImageReadyEmitted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.firstImageReadyEmitted = true;
|
||||||
|
this.firstImageReady.emit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,10 @@
|
|||||||
}
|
}
|
||||||
@default {
|
@default {
|
||||||
<app-product-column-with-image
|
<app-product-column-with-image
|
||||||
[imageUrl]="item.image ?? null"
|
[imageUrl]="loadImages() ? (item.image ?? null) : null"
|
||||||
[title]="item.nombre"
|
[title]="item.nombre"
|
||||||
[originalPrice]="price(item)"
|
[originalPrice]="price(item)"
|
||||||
[imagePriority]="index < 4"
|
[imagePriority]="loadImages() && index < 4"
|
||||||
(buy)="buy.emit(item)"
|
(buy)="buy.emit(item)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export class ProductListComponent {
|
|||||||
readonly groupLayout = input.required<CatalogGroupLayout>();
|
readonly groupLayout = input.required<CatalogGroupLayout>();
|
||||||
readonly items = input.required<CatalogFeaturedItems>();
|
readonly items = input.required<CatalogFeaturedItems>();
|
||||||
readonly loading = input(false);
|
readonly loading = input(false);
|
||||||
|
readonly loadImages = input(true);
|
||||||
|
|
||||||
readonly buy = output<ProductListItem>();
|
readonly buy = output<ProductListItem>();
|
||||||
readonly addToCart = output<ProductListCartEvent>();
|
readonly addToCart = output<ProductListCartEvent>();
|
||||||
|
|||||||
Reference in New Issue
Block a user