From 625990f594bdb55d5e7c774a0f4d516b68921a38 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 17 Jul 2026 14:14:05 -0300 Subject: [PATCH] feat(product-vertical-with-cart-card): add reusable vertical product card component with cart functionality --- .../reutilizables-test-page.component.html | 24 +++- .../reutilizables-test-page.component.ts | 10 ++ ...uct-vertical-with-cart-card.component.html | 21 ++++ ...uct-vertical-with-cart-card.component.scss | 103 +++++++++++++++ ...-vertical-with-cart-card.component.spec.ts | 118 ++++++++++++++++++ ...oduct-vertical-with-cart-card.component.ts | 36 ++++++ 6 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html create mode 100644 src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss create mode 100644 src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts create mode 100644 src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts 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 831ea18..0a3fe1d 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 @@ -655,6 +655,29 @@ +
+
+ Reusable Component +

Tarjetas de Producto (Vertical With Cart Layout)

+ +

+ Diseño vertical con selector de cantidad y acciones de compra y carrito. +

+ +
+
+ +
+
+
+
+ - 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 e1775a2..8922116 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 @@ -10,6 +10,9 @@ import { InputComponent } from '../../../../shared/components/input/input.compon import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component'; import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component'; import { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component'; +import { + ProductVerticalWithCartCardComponent +} from '../../../../shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component'; import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component'; import { ModalService } from '../../../../core/services/modal.service'; import { TenantService } from '../../../../core/services/tenant.service'; @@ -26,6 +29,7 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon PaginatorComponent, ProductCardComponent, ProductRowCardComponent, + ProductVerticalWithCartCardComponent, StoreSectionComponent, IconButtonComponent, CartIconComponent, @@ -98,6 +102,12 @@ export class ReutilizablesTestPageComponent { ] }; + protected readonly testVerticalWithCartProduct = { + title: 'PANCHO', + description: 'Pancho con aderezo a elección.', + price: 11500 + }; + protected readonly cartMockItems: CartItemMock[] = [ { imageUrl: null, diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html new file mode 100644 index 0000000..caa9268 --- /dev/null +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html @@ -0,0 +1,21 @@ +
+
+

{{ title() }}

+ + @if (description()) { +

{{ description() }}

+ } +
+ +
+
+ {{ formattedPrice() }} + +
+ +
+ Comprar + Agregar al carrito +
+
+
diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss new file mode 100644 index 0000000..23c4b01 --- /dev/null +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss @@ -0,0 +1,103 @@ +:host { + display: block; + width: 100%; + min-width: 0; + max-width: 100%; + height: 100%; + box-sizing: border-box; +} + +.product-vertical-with-cart-card { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 100%; + min-width: 0; + max-width: 100%; + height: 100%; + box-sizing: border-box; + padding: 30px 20px; + overflow: hidden; + background-color: #ffffff; + border: 1px solid #dedede; + border-radius: 7px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + + &__content { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + } + + &__title { + margin: 0; + color: #666666; + font-size: 15px; + font-weight: 700; + line-height: 1.2; + text-transform: uppercase; + } + + &__description { + margin: 23px 0 ; + color: #6f6f6f; + font-size: 11px; + font-weight: 300; + line-height: 1.45; + } + + &__purchase { + display: grid; + gap: 24px; + } + + &__summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-inline: 12px; + } + + &__price { + color: var(--tenant-primary, #009933); + font-size: 22px; + font-weight: 800; + line-height: 1; + white-space: nowrap; + } + + &__actions { + display: grid; + gap: 10px; + } + + &__actions app-button { + display: block; + width: 100%; + } + + &__actions ::ng-deep .btn { + width: 100%; + min-height: 40px; + min-width: 0; + border-radius: 7px; + font-size: 15px; + } +} + +@media (max-width: 420px) { + .product-vertical-with-cart-card { + padding: 42px 20px 21px; + + &__purchase { + gap: 24px; + } + + &__summary { + padding-inline: 12px; + } + + } +} diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts new file mode 100644 index 0000000..2497df1 --- /dev/null +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts @@ -0,0 +1,118 @@ +import { TestBed, getTestBed } from '@angular/core/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { ProductVerticalWithCartCardComponent } from './product-vertical-with-cart-card.component'; + +describe('ProductVerticalWithCartCardComponent', () => { + beforeAll(() => { + try { + getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); + } catch { + // Test environment may already be initialized by another setup entrypoint. + } + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + async function createComponent(description = 'Pancho con aderezo a elección.') { + await TestBed.configureTestingModule({ + imports: [ProductVerticalWithCartCardComponent], + }).compileComponents(); + + const fixture = TestBed.createComponent(ProductVerticalWithCartCardComponent); + fixture.componentRef.setInput('title', 'Pancho'); + fixture.componentRef.setInput('description', description); + fixture.componentRef.setInput('price', 11500); + fixture.detectChanges(); + + return fixture; + } + + it('renders the product information and formats the price', async () => { + const fixture = await createComponent(); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('.product-vertical-with-cart-card__title')?.textContent).toContain( + 'Pancho', + ); + expect( + element.querySelector('.product-vertical-with-cart-card__description')?.textContent, + ).toContain('Pancho con aderezo a elección.'); + expect( + element.querySelector('.product-vertical-with-cart-card__price')?.textContent?.trim(), + ).toBe('$ 11.500'); + }); + + it('does not render an empty description', async () => { + const fixture = await createComponent(''); + + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '.product-vertical-with-cart-card__description', + ), + ).toBeNull(); + }); + + it('updates the quantity with the reusable quantity selector', async () => { + const fixture = await createComponent(); + const buttons = fixture.nativeElement.querySelectorAll( + 'app-quantity-selector button', + ) as NodeListOf; + + expect(fixture.componentInstance.quantity()).toBe(1); + + buttons[1].click(); + fixture.detectChanges(); + expect(fixture.componentInstance.quantity()).toBe(2); + + buttons[0].click(); + fixture.detectChanges(); + expect(fixture.componentInstance.quantity()).toBe(1); + }); + + it('emits buy when Comprar is clicked', async () => { + const fixture = await createComponent(); + const buySpy = vi.fn(); + fixture.componentInstance.buy.subscribe(buySpy); + + const button = fixture.nativeElement.querySelector( + '.product-vertical-with-cart-card__actions .btn-primary', + ) as HTMLButtonElement; + button.click(); + + expect(buySpy).toHaveBeenCalledOnce(); + }); + + it('emits addToCart with the current quantity', async () => { + const fixture = await createComponent(); + const addToCartSpy = vi.fn(); + fixture.componentInstance.addToCart.subscribe(addToCartSpy); + fixture.componentInstance.quantity.set(3); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector( + '.product-vertical-with-cart-card__actions .btn-outline-primary', + ) as HTMLButtonElement; + button.click(); + + expect(addToCartSpy).toHaveBeenCalledWith({ quantity: 3 }); + }); + + it('uses the shared primary and secondary buttons', async () => { + const fixture = await createComponent(); + const element = fixture.nativeElement as HTMLElement; + + expect( + element.querySelectorAll('.product-vertical-with-cart-card__actions app-button'), + ).toHaveLength(2); + expect( + element.querySelector('.product-vertical-with-cart-card__actions .btn-primary'), + ).not.toBeNull(); + expect( + element.querySelector('.product-vertical-with-cart-card__actions .btn-outline-primary'), + ).not.toBeNull(); + }); +}); diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts new file mode 100644 index 0000000..277b8c5 --- /dev/null +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts @@ -0,0 +1,36 @@ +import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core'; + +import { ButtonComponent } from '../button/button.component'; +import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; + +@Component({ + selector: 'app-product-vertical-with-cart-card', + imports: [ButtonComponent, QuantitySelectorComponent], + templateUrl: './product-vertical-with-cart-card.component.html', + styleUrl: './product-vertical-with-cart-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProductVerticalWithCartCardComponent { + readonly title = input(''); + readonly description = input(''); + readonly price = input(0); + + readonly quantity = model(1); + + readonly buy = output(); + readonly addToCart = output<{ quantity: number }>(); + + protected readonly formattedPrice = computed(() => this.formatCurrency(this.price())); + + protected onAddToCart(): void { + this.addToCart.emit({ quantity: this.quantity() }); + } + + private formatCurrency(value: number): string { + const rounded = Math.round(value); + const parts = rounded.toString().split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.'); + + return `$ ${parts.join(',')}`; + } +}