From b04c33bbffc28932b0a9afb61a2393e94a0b3ad2 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 23 Jul 2026 14:27:03 -0300 Subject: [PATCH] feat(carousel): add main carousel component with image support and integrate into store home page --- src/app/core/services/tenant.interface.ts | 1 + .../reutilizables-test-page.component.html | 18 ++++ .../reutilizables-test-page.component.spec.ts | 35 ++++++++ .../reutilizables-test-page.component.ts | 12 +++ .../store-home-page.component.html | 10 +++ .../store-home-page.component.scss | 4 + .../store-home-page.component.spec.ts | 88 ++++++++++++++++++ .../store-home-page.component.ts | 8 +- .../main-carousel.component.html | 41 +++++++++ .../main-carousel.component.scss | 90 +++++++++++++++++++ .../main-carousel.component.spec.ts | 84 +++++++++++++++++ .../main-carousel/main-carousel.component.ts | 86 ++++++++++++++++++ 12 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 src/app/shared/components/main-carousel/main-carousel.component.html create mode 100644 src/app/shared/components/main-carousel/main-carousel.component.scss create mode 100644 src/app/shared/components/main-carousel/main-carousel.component.spec.ts create mode 100644 src/app/shared/components/main-carousel/main-carousel.component.ts diff --git a/src/app/core/services/tenant.interface.ts b/src/app/core/services/tenant.interface.ts index 322df3a..7be0295 100644 --- a/src/app/core/services/tenant.interface.ts +++ b/src/app/core/services/tenant.interface.ts @@ -46,6 +46,7 @@ export interface Tenant { selected_bank_account?: BankAccount | null; hero_config?: HeroConfig | null; event_config?: EventConfig | null; + main_carousel_images?: string[]; menues?: Menu[]; } diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html index 87a32ad..f164026 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html @@ -574,6 +574,24 @@ +
+
+ Reusable Component +

Main Carousel

+ +

+ Carrusel panorámico compuesto únicamente por imágenes, con autoslide e indicadores. +

+ + +
+
+
Reusable Component diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts index 30cba1e..73b2dcd 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts @@ -180,6 +180,41 @@ describe('ReutilizablesTestPageComponent', () => { ).toBe(6); }); + it('renders the main carousel demo with remote images', async () => { + await TestBed.configureTestingModule({ + imports: [ReutilizablesTestPageComponent], + providers: [ + { + provide: TenantService, + useValue: createTenantServiceStub(tenant), + }, + { + provide: ToastService, + useValue: createToastServiceStub(), + }, + { + provide: ModalService, + useValue: createModalServiceStub(), + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(ReutilizablesTestPageComponent); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + const carouselDemo = element.querySelector('[data-testid="main-carousel"]'); + const images = carouselDemo?.querySelectorAll('.main-carousel__image'); + const indicators = carouselDemo?.querySelectorAll('.main-carousel__indicator'); + + expect(carouselDemo?.querySelector('app-main-carousel')).not.toBeNull(); + expect(images).toHaveLength(3); + expect(indicators).toHaveLength(3); + expect(images?.[0].getAttribute('src')).toContain('images.unsplash.com'); + + fixture.destroy(); + }); + it('renders the modal showcase and opens the confirm demos from the buttons', async () => { const modalServiceStub = createModalServiceStub(); await TestBed.configureTestingModule({ diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index 82886d8..bb0f291 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -15,6 +15,7 @@ import { ToastService } from '../../../../core/services/toast.service'; import { StepperComponent } from '../../../../shared/components/stepper/stepper.component'; import { StepComponent } from '../../../../shared/components/stepper/step.component'; import { CarouselComponent } from '../../../../shared/components/carousel/carousel.component'; +import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component'; interface CarouselProductMock { id: number; @@ -41,6 +42,7 @@ interface CarouselProductMock { StepperComponent, StepComponent, CarouselComponent, + MainCarouselComponent, ], templateUrl: './reutilizables-test-page.component.html', styleUrl: './reutilizables-test-page.component.scss', @@ -73,6 +75,16 @@ export class ReutilizablesTestPageComponent { protected lastModalResult = 'Todavia no se abrio ningun modal.'; protected readonly paginatorTotalPages = 8; protected readonly cartBackgroundColor = '#ffffff'; + protected readonly mainCarouselImages = [ + 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&w=1600&q=80', + 'https://images.unsplash.com/photo-1529139574466-a303027c1d8b?auto=format&fit=crop&w=1600&q=80', + 'https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=1600&q=80', + ]; + protected readonly mainCarouselImageAlts = [ + 'Interior de una tienda de indumentaria', + 'Colección de moda en exteriores', + 'Percheros con prendas de distintos colores', + ]; protected readonly testProducts = [ { diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.html b/src/app/features/store/pages/store-home-page/store-home-page.component.html index da12b25..625108a 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.html +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.html @@ -1,3 +1,13 @@ +@if (tenant()?.main_carousel_images; as mainCarouselImages) { + @if (mainCarouselImages.length) { + + } +} + @if (tenant()?.hero_config || tenant()?.event_config) { tenantState(), + }; +} + describe('StoreHomePageComponent', () => { beforeEach(() => vi.restoreAllMocks()); @@ -115,6 +145,64 @@ describe('StoreHomePageComponent', () => { ); }); + it('renders the tenant main carousel images at the beginning of the home page', async () => { + const mainCarouselImages = [ + 'https://example.com/carousel-1.webp', + 'https://example.com/carousel-2.webp', + ]; + + await TestBed.configureTestingModule({ + imports: [StoreHomePageComponent], + providers: [ + provideActivatedRoute({ response: createCatalog(), error: null }), + { + provide: CatalogService, + useValue: { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() }, + }, + { + provide: TenantService, + useValue: createTenantServiceStub(createTenant(mainCarouselImages)), + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(StoreHomePageComponent); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + const carousel = element.querySelector('app-main-carousel'); + const images = carousel?.querySelectorAll('.main-carousel__image'); + + expect(element.firstElementChild).toBe(carousel); + expect(images).toHaveLength(2); + expect(images?.[0].getAttribute('src')).toBe(mainCarouselImages[0]); + expect(images?.[1].getAttribute('src')).toBe(mainCarouselImages[1]); + + fixture.destroy(); + }); + + it('does not render the main carousel when the tenant has no images', async () => { + await TestBed.configureTestingModule({ + imports: [StoreHomePageComponent], + providers: [ + provideActivatedRoute({ response: createCatalog(), error: null }), + { + provide: CatalogService, + useValue: { getCatalog: vi.fn(), getFeaturedGroupItems: vi.fn() }, + }, + { + provide: TenantService, + useValue: createTenantServiceStub(createTenant([])), + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(StoreHomePageComponent); + fixture.detectChanges(); + + expect((fixture.nativeElement as HTMLElement).querySelector('app-main-carousel')).toBeNull(); + }); + it('requests another page for the selected featured group', async () => { const pageTwoItems = [{ id: 3, nombre: 'Mouse Gamer', precio: '15999.00', image: null }]; const catalogServiceStub = { diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.ts index 4ddc3fa..cdfce5f 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.ts @@ -21,6 +21,7 @@ import { ProductListItem, } from '../../../../shared/components/product-list/product-list.component'; import { HeroBannerComponent } from '../../../../shared/components/hero-banner/hero-banner.component'; +import { MainCarouselComponent } from '../../../../shared/components/main-carousel/main-carousel.component'; import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component'; import { STORE_HOME_PRODUCTS_ERROR_MESSAGE, @@ -29,7 +30,12 @@ import { @Component({ selector: 'app-store-home-page', - imports: [StoreSectionComponent, ProductListComponent, HeroBannerComponent], + imports: [ + StoreSectionComponent, + ProductListComponent, + HeroBannerComponent, + MainCarouselComponent, + ], templateUrl: './store-home-page.component.html', styleUrl: './store-home-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/shared/components/main-carousel/main-carousel.component.html b/src/app/shared/components/main-carousel/main-carousel.component.html new file mode 100644 index 0000000..1998a4d --- /dev/null +++ b/src/app/shared/components/main-carousel/main-carousel.component.html @@ -0,0 +1,41 @@ + diff --git a/src/app/shared/components/main-carousel/main-carousel.component.scss b/src/app/shared/components/main-carousel/main-carousel.component.scss new file mode 100644 index 0000000..bd9a335 --- /dev/null +++ b/src/app/shared/components/main-carousel/main-carousel.component.scss @@ -0,0 +1,90 @@ +:host { + display: block; + min-width: 0; +} + +.main-carousel { + position: relative; + width: 100%; + overflow: hidden; + background: #dedede; + + &:focus-visible { + outline: 2px solid var(--color-primary, #6376f3); + outline-offset: 2px; + } + + &__viewport { + position: relative; + width: 100%; + aspect-ratio: 4 / 1; + min-height: 9rem; + max-height: 25rem; + } + + &__image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + visibility: hidden; + transition: + opacity 500ms ease, + visibility 500ms ease; + user-select: none; + + &--active { + opacity: 1; + visibility: visible; + } + } + + &__indicators { + position: absolute; + z-index: 1; + bottom: clamp(0.45rem, 1.2vw, 0.8rem); + left: 50%; + display: flex; + gap: 0.25rem; + transform: translateX(-50%); + } + + &__indicator { + width: 0.32rem; + height: 0.32rem; + padding: 0; + cursor: pointer; + background: rgba(255, 255, 255, 0.55); + border: 0; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12); + transition: + background-color 150ms ease, + transform 150ms ease; + + &:hover, + &--active { + background: #ffffff; + transform: scale(1.2); + } + + &:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 2px; + } + } +} + +@media (max-width: 576px) { + .main-carousel__viewport { + aspect-ratio: 2 / 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .main-carousel__image { + transition: none; + } +} diff --git a/src/app/shared/components/main-carousel/main-carousel.component.spec.ts b/src/app/shared/components/main-carousel/main-carousel.component.spec.ts new file mode 100644 index 0000000..667a851 --- /dev/null +++ b/src/app/shared/components/main-carousel/main-carousel.component.spec.ts @@ -0,0 +1,84 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MainCarouselComponent } from './main-carousel.component'; + +@Component({ + imports: [MainCarouselComponent], + template: ` + + `, +}) +class TestHostComponent { + readonly images = ['/images/one.webp', '/images/two.webp', '/images/three.webp']; +} + +describe('MainCarouselComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + }); + + it('renderiza solamente las imágenes y sus indicadores', () => { + const element = fixture.nativeElement as HTMLElement; + const images = element.querySelectorAll('.main-carousel__image'); + const indicators = element.querySelectorAll('.main-carousel__indicator'); + + expect(images).toHaveLength(3); + expect(indicators).toHaveLength(3); + expect(images[0].getAttribute('alt')).toBe('Primera promoción'); + expect(images[0].classList).toContain('main-carousel__image--active'); + }); + + it('avanza automáticamente entre las imágenes', () => { + fixture.destroy(); + vi.useFakeTimers(); + + try { + fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + vi.advanceTimersByTime(1000); + fixture.detectChanges(); + + const images = fixture.nativeElement.querySelectorAll('.main-carousel__image'); + expect(images[1].classList).toContain('main-carousel__image--active'); + } finally { + fixture.destroy(); + vi.useRealTimers(); + } + }); + + it('permite seleccionar una imagen desde los indicadores', () => { + const indicators = fixture.nativeElement.querySelectorAll( + '.main-carousel__indicator', + ) as NodeListOf; + + indicators[2].click(); + fixture.detectChanges(); + + const images = fixture.nativeElement.querySelectorAll('.main-carousel__image'); + expect(images[2].classList).toContain('main-carousel__image--active'); + expect(indicators[2].getAttribute('aria-current')).toBe('true'); + }); + + it('permite navegar con las flechas del teclado', () => { + const carousel = fixture.nativeElement.querySelector('.main-carousel') as HTMLElement; + + carousel.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + fixture.detectChanges(); + + const images = fixture.nativeElement.querySelectorAll('.main-carousel__image'); + expect(images[2].classList).toContain('main-carousel__image--active'); + }); +}); diff --git a/src/app/shared/components/main-carousel/main-carousel.component.ts b/src/app/shared/components/main-carousel/main-carousel.component.ts new file mode 100644 index 0000000..4c10deb --- /dev/null +++ b/src/app/shared/components/main-carousel/main-carousel.component.ts @@ -0,0 +1,86 @@ +import { + ChangeDetectionStrategy, + Component, + PLATFORM_ID, + computed, + effect, + inject, + input, + signal, +} from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +@Component({ + selector: 'app-main-carousel', + templateUrl: './main-carousel.component.html', + styleUrl: './main-carousel.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MainCarouselComponent { + private readonly platformId = inject(PLATFORM_ID); + private readonly requestedIndex = signal(0); + private readonly paused = signal(false); + + readonly images = input.required(); + readonly imageAlts = input([]); + readonly autoSlideInterval = input(5000); + readonly ariaLabel = input('Imágenes destacadas'); + + protected readonly activeIndex = computed(() => { + const imageCount = this.images().length; + + return imageCount ? this.requestedIndex() % imageCount : 0; + }); + protected readonly hasMultipleImages = computed(() => this.images().length > 1); + + constructor() { + effect(() => { + const imageCount = this.images().length; + const interval = Math.max(this.autoSlideInterval(), 1000); + + if (!isPlatformBrowser(this.platformId) || imageCount < 2) { + return; + } + + const timer = window.setInterval(() => { + if (!this.paused()) { + this.requestedIndex.update((index) => (index + 1) % imageCount); + } + }, interval); + + return () => window.clearInterval(timer); + }); + } + + protected selectImage(index: number): void { + if (index >= 0 && index < this.images().length) { + this.requestedIndex.set(index); + } + } + + protected setPaused(paused: boolean): void { + this.paused.set(paused); + } + + protected handleKeydown(event: KeyboardEvent): void { + if (!this.hasMultipleImages()) { + return; + } + + if (event.key === 'ArrowLeft') { + event.preventDefault(); + this.requestedIndex.update( + (index) => (index - 1 + this.images().length) % this.images().length, + ); + } + + if (event.key === 'ArrowRight') { + event.preventDefault(); + this.requestedIndex.update((index) => (index + 1) % this.images().length); + } + } + + protected imageAlt(index: number): string { + return this.imageAlts()[index] || `Imagen destacada ${index + 1}`; + } +}