feat(carousel): implement product carousel component with mock data and styles

This commit is contained in:
2026-07-23 11:47:10 -03:00
parent 20c8b04638
commit 3e98d08d59
8 changed files with 542 additions and 61 deletions

View File

@@ -574,6 +574,38 @@
</div>
</section>
<section class="component-demo card shadow-sm mt-4" data-testid="products-carousel">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-3">Carousel de productos</h2>
<p class="text-muted mb-4">
El carousel controla únicamente el desplazamiento. Cada ítem se define externamente con un
template y en este ejemplo renderiza productos con imágenes mockeadas.
</p>
<ng-template #productCarouselItem let-product let-index="index">
<div class="carousel-product" [attr.data-carousel-index]="index">
<app-product-column-with-image
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
[discount]="product.discount"
[transferPrice]="product.transferPrice"
(buy)="onProductBuy(product.title)"
/>
</div>
</ng-template>
<app-carousel
[items]="carouselProducts"
[itemTemplate]="productCarouselItem"
[trackBy]="trackCarouselProduct"
ariaLabel="Productos con imágenes de prueba"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>

View File

@@ -19,6 +19,17 @@
width: min(100%, 64rem);
}
.carousel-product {
width: clamp(15rem, 30vw, 18rem);
height: 100%;
margin-right: 1rem;
app-product-column-with-image {
display: block;
height: 100%;
}
}
.eyebrow {
display: inline-block;
margin-bottom: 0.75rem;
@@ -155,7 +166,6 @@ h1 {
gap: 0.75rem;
}
.file-field {
display: grid;
gap: 0.75rem;
@@ -168,6 +178,10 @@ h1 {
}
@media (max-width: 767.98px) {
.carousel-product {
width: min(18rem, calc(100vw - 5rem));
}
.input-grid {
grid-template-columns: minmax(0, 1fr);
}
@@ -206,11 +220,15 @@ h1 {
padding: 1rem;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05), 0 4px 12px rgba(0, 0, 0, 0.08);
box-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.05),
0 4px 12px rgba(0, 0, 0, 0.08);
}
}
@@ -251,7 +269,9 @@ h1 {
border: 1px solid #dee2e6;
overflow: hidden;
background-color: #ffffff;
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-3px);

View File

@@ -1,9 +1,6 @@
import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { of } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -25,7 +22,7 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
footer_logo: 'https://example.com/footer.png',
};
function createTenantServiceStub(currentTenant: Tenant | null) {
@@ -36,7 +33,7 @@ function createTenantServiceStub(currentTenant: Tenant | null) {
tenant: tenantState.asReadonly(),
status: statusState.asReadonly(),
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined)
bootstrap: vi.fn().mockResolvedValue(undefined),
};
}
@@ -44,7 +41,7 @@ function createToastServiceStub() {
return {
success: vi.fn(),
danger: vi.fn(),
info: vi.fn()
info: vi.fn(),
};
}
@@ -52,17 +49,14 @@ function createModalServiceStub() {
return {
open: vi.fn(),
openConfirm: vi.fn().mockReturnValue(of(true)),
openConfirmDelete: vi.fn().mockReturnValue(of(false))
openConfirmDelete: vi.fn().mockReturnValue(of(false)),
};
}
describe('ReutilizablesTestPageComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
@@ -79,17 +73,17 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub()
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
useValue: modalServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
@@ -101,12 +95,12 @@ describe('ReutilizablesTestPageComponent', () => {
expect(sectionTitle?.textContent).toContain('Configuracion Tenant');
const headerImg = element.querySelector(
'.tenant-logo-card img[alt="Header Logo"]'
'.tenant-logo-card img[alt="Header Logo"]',
) as HTMLImageElement;
expect(headerImg?.src).toBe(tenant.header_logo);
const footerImg = element.querySelector(
'.tenant-logo-card img[alt="Footer Logo"]'
'.tenant-logo-card img[alt="Footer Logo"]',
) as HTMLImageElement;
expect(footerImg?.src).toBe(tenant.footer_logo);
@@ -123,17 +117,17 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub()
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
useValue: modalServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
@@ -142,9 +136,43 @@ describe('ReutilizablesTestPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('app-paginator')).not.toBeNull();
expect(
element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()
).toBe('1/8');
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
'1/8',
);
});
it('renders the carousel demo with mocked product 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="products-carousel"]');
const carouselItems = carouselDemo?.querySelectorAll('[data-carousel-index]');
const productImages = carouselDemo?.querySelectorAll('app-product-column-with-image img');
expect(carouselDemo?.querySelector('app-carousel')).not.toBeNull();
expect(carouselItems).toHaveLength(6);
expect(productImages).toHaveLength(6);
expect(productImages?.[0].getAttribute('src')).toContain('/images/pantalon-scuba.png');
expect(productImages?.[1].getAttribute('src')).toContain('/images/zapatillas-running.png');
});
it('renders the modal showcase and opens the confirm demos from the buttons', async () => {
@@ -154,36 +182,34 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub()
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
useValue: modalServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const buttons = Array.from(
element.querySelectorAll('.button-group app-button button')
);
const buttons = Array.from(element.querySelectorAll('.button-group app-button button'));
const confirmButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm modal')
button.textContent?.includes('Abrir confirm modal'),
) as HTMLButtonElement;
const deleteButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm delete')
button.textContent?.includes('Abrir confirm delete'),
) as HTMLButtonElement;
expect(
element.querySelector('[data-testid="modal-last-result"]')?.textContent
).toContain('Todavia no se abrio ningun modal.');
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Todavia no se abrio ningun modal.',
);
confirmButton.click();
fixture.detectChanges();
@@ -193,17 +219,17 @@ describe('ReutilizablesTestPageComponent', () => {
expect(modalServiceStub.openConfirm).toHaveBeenCalledWith({
title: 'Confirmar accion',
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar'
confirmLabel: 'Confirmar',
});
expect(modalServiceStub.openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto',
content:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar'
confirmLabel: 'Eliminar',
});
expect(
element.querySelector('[data-testid="modal-last-result"]')?.textContent
).toContain('Resultado: false');
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Resultado: false',
);
});
it('updates the visible result when the confirm modal resolves to true', async () => {
@@ -213,17 +239,17 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub()
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
useValue: modalServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
@@ -231,16 +257,14 @@ describe('ReutilizablesTestPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
const confirmButton = Array.from(
element.querySelectorAll('.button-group app-button button')
).find((button) =>
button.textContent?.includes('Abrir confirm modal')
) as HTMLButtonElement;
element.querySelectorAll('.button-group app-button button'),
).find((button) => button.textContent?.includes('Abrir confirm modal')) as HTMLButtonElement;
confirmButton.click();
fixture.detectChanges();
expect(
element.querySelector('[data-testid="modal-last-result"]')?.textContent
).toContain('Resultado: true');
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Resultado: true',
);
});
});

View File

@@ -14,6 +14,16 @@ import { TenantService } from '../../../../core/services/tenant.service';
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';
interface CarouselProductMock {
id: number;
title: string;
originalPrice: number;
discount: number | null;
transferPrice: number;
imageUrl: string;
}
@Component({
selector: 'app-reutilizables-test-page',
@@ -30,6 +40,7 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
CartIconComponent,
StepperComponent,
StepComponent,
CarouselComponent,
],
templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss',
@@ -87,6 +98,62 @@ export class ReutilizablesTestPageComponent {
},
];
protected readonly carouselProducts: CarouselProductMock[] = [
{
id: 1,
title: 'Pantalón scuba negro',
originalPrice: 95000,
discount: 20,
transferPrice: 74800,
imageUrl: '/images/pantalon-scuba.png',
},
{
id: 2,
title: 'Zapatillas running azul',
originalPrice: 120000,
discount: 15,
transferPrice: 98000,
imageUrl: '/images/zapatillas-running.png',
},
{
id: 3,
title: 'Pantalón urbano',
originalPrice: 89000,
discount: 10,
transferPrice: 78500,
imageUrl: '/images/pantalon-scuba.png',
},
{
id: 4,
title: 'Zapatillas deportivas',
originalPrice: 135000,
discount: null,
transferPrice: 128000,
imageUrl: '/images/zapatillas-running.png',
},
{
id: 5,
title: 'Pantalón clásico',
originalPrice: 102000,
discount: 25,
transferPrice: 75000,
imageUrl: '/images/pantalon-scuba.png',
},
{
id: 6,
title: 'Zapatillas training',
originalPrice: 142000,
discount: 12,
transferPrice: 121000,
imageUrl: '/images/zapatillas-running.png',
},
];
protected readonly trackCarouselProduct = (
_index: number,
product: CarouselProductMock,
): number => product.id;
protected readonly testRowProduct = {
title: 'ENTRADA GENERAL',
description: