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 @@
+
+
+ @for (image of images(); track $index; let index = $index) {
+
![]()
+ }
+
+
+ @if (hasMultipleImages()) {
+
+ @for (image of images(); track $index; let index = $index) {
+
+ }
+
+ }
+
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}`;
+ }
+}