From c0eb3369c5c10e99b483fba178c85f2c3f2c1a00 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 20 Jul 2026 14:27:24 -0300 Subject: [PATCH] feat(product-list): replace ProductCard with ProductColumnWithImage and update related components --- .../reutilizables-test-page.component.html | 6 +- .../reutilizables-test-page.component.scss | 2 +- .../reutilizables-test-page.component.ts | 5 +- .../store-home-page.component.html | 2 +- .../store-home-page.component.spec.ts | 4 +- .../store-home-page.component.ts | 9 ++- .../product-card/product-card.component.html | 66 ---------------- .../product-card/product-card.component.ts | 71 ----------------- .../product-column-with-image.component.html | 61 ++++++++++++++ .../product-column-with-image.component.scss} | 9 ++- .../product-column-with-image.component.ts | 48 +++++++++++ .../product-list/product-list.component.html | 40 ++++++++++ .../product-list/product-list.component.scss | 27 +++++++ .../product-list.component.spec.ts | 73 +++++++++++++++++ .../product-list/product-list.component.ts | 79 +++++++++++++++++++ 15 files changed, 349 insertions(+), 153 deletions(-) delete mode 100644 src/app/shared/components/product-card/product-card.component.html delete mode 100644 src/app/shared/components/product-card/product-card.component.ts create mode 100644 src/app/shared/components/product-column-with-image/product-column-with-image.component.html rename src/app/shared/components/{product-card/product-card.component.scss => product-column-with-image/product-column-with-image.component.scss} (90%) create mode 100644 src/app/shared/components/product-column-with-image/product-column-with-image.component.ts create mode 100644 src/app/shared/components/product-list/product-list.component.html create mode 100644 src/app/shared/components/product-list/product-list.component.scss create mode 100644 src/app/shared/components/product-list/product-list.component.spec.ts create mode 100644 src/app/shared/components/product-list/product-list.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 0a3fe1d..1af0515 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 @@ -607,7 +607,7 @@
Reusable Component -

Tarjetas de Producto (ProductCard)

+

Tarjetas de Producto (Column With Image)

Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se @@ -617,7 +617,7 @@

@for (product of testProducts; track product.title) {
- - @for (product of productCards(); track product.id; let index = $index) {
- { expect(catalogServiceStub.getProductos).not.toHaveBeenCalled(); expect(element.querySelector('.store-section__title')?.textContent?.trim()).toBe('Productos'); - expect(element.querySelectorAll('app-product-card')).toHaveLength(2); + expect(element.querySelectorAll('app-product-column-with-image')).toHaveLength(2); expect(element.textContent).toContain('Auriculares Bluetooth'); expect(element.textContent).toContain('Teclado Mecanico'); expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe( @@ -267,7 +267,7 @@ describe('StoreHomePageComponent', () => { const element = fixture.nativeElement as HTMLElement; expect(element.textContent).toContain('No pudimos cargar los productos en este momento.'); - expect(element.querySelectorAll('app-product-card')).toHaveLength(0); + expect(element.querySelectorAll('app-product-column-with-image')).toHaveLength(0); expect(catalogServiceStub.getProductos).not.toHaveBeenCalled(); }); }); 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 cc866aa..c28779e 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 @@ -13,7 +13,7 @@ import { Subscription } from 'rxjs'; import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface'; import { CatalogService } from '../../../../core/services/catalog/catalog.service'; import { Product } from '../../../../core/services/catalog/catalog.interface'; -import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component'; +import { ProductColumnWithImageComponent } from '../../../../shared/components/product-column-with-image/product-column-with-image.component'; import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component'; import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component'; import { HeroBannerComponent } from '../../../../shared/components/hero-banner/hero-banner.component'; @@ -34,7 +34,12 @@ interface StoreHomeProductCardViewModel { @Component({ selector: 'app-store-home-page', - imports: [StoreSectionComponent, ProductCardComponent, PaginatorComponent, HeroBannerComponent], + imports: [ + StoreSectionComponent, + ProductColumnWithImageComponent, + PaginatorComponent, + HeroBannerComponent, + ], templateUrl: './store-home-page.component.html', styleUrl: './store-home-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/shared/components/product-card/product-card.component.html b/src/app/shared/components/product-card/product-card.component.html deleted file mode 100644 index b03647f..0000000 --- a/src/app/shared/components/product-card/product-card.component.html +++ /dev/null @@ -1,66 +0,0 @@ -
- -
- @if (imageUrl(); as imageSrc) { - - } @else { -
- -
- } - - - @if (discount() && discount()! > 0) { - - -{{ discount() }}% - - } -
- - -
- -

{{ title() }}

- - -
- - {{ - formattedDiscountedPrice() - }} - - - @if (discount() && discount()! > 0) { - {{ - formattedOriginalPrice() - }} - } -
- - -
- {{ formattedTransferPrice() }} - con transferencia -
- - -
- - {{ buttonText() }} - -
-
-
diff --git a/src/app/shared/components/product-card/product-card.component.ts b/src/app/shared/components/product-card/product-card.component.ts deleted file mode 100644 index 78c8eff..0000000 --- a/src/app/shared/components/product-card/product-card.component.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; -import { NgOptimizedImage } from '@angular/common'; -import { ButtonComponent } from '../button/button.component'; - -@Component({ - selector: 'app-product-card', - standalone: true, - imports: [ButtonComponent, NgOptimizedImage], - templateUrl: './product-card.component.html', - styleUrl: './product-card.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ProductCardComponent { - // Configurable inputs - readonly imageUrl = input(null); - readonly title = input(''); - readonly originalPrice = input(0); - readonly discount = input(null); - readonly transferPrice = input(null); - readonly buttonText = input('Comprar'); - readonly imagePriority = input(false); - - // Interactive events - readonly buy = output(); - - // Computed discounted price - readonly discountedPrice = computed(() => { - const original = this.originalPrice(); - const disc = this.discount(); - if (!disc || disc <= 0) { - return original; - } - return original * (1 - disc / 100); - }); - - // Computed transfer price - readonly computedTransferPrice = computed(() => { - const givenTransfer = this.transferPrice(); - if (givenTransfer !== null) { - return givenTransfer; - } - // Fallback if not specified: use the discounted price - return this.discountedPrice(); - }); - - // Formatted string for discounted price: "$ 76.000" - readonly formattedDiscountedPrice = computed(() => { - return this.formatCurrency(this.discountedPrice()); - }); - - // Formatted string for original price (if has discount): "$ 95.000" - readonly formattedOriginalPrice = computed(() => { - return this.formatCurrency(this.originalPrice()); - }); - - // Formatted string for transfer price: "$ 74.800" - readonly formattedTransferPrice = computed(() => { - return this.formatCurrency(this.computedTransferPrice()); - }); - - /** - * Helper method to format currency numbers to Argentine Pesos style "$ XX.XXX". - * This manual implementation is safe from runtime/SSR locale variations. - */ - 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(',')}`; - } -} diff --git a/src/app/shared/components/product-column-with-image/product-column-with-image.component.html b/src/app/shared/components/product-column-with-image/product-column-with-image.component.html new file mode 100644 index 0000000..e63b5aa --- /dev/null +++ b/src/app/shared/components/product-column-with-image/product-column-with-image.component.html @@ -0,0 +1,61 @@ +
+
+ @if (imageUrl(); as imageSrc) { + + } @else { +
+ +
+ } + + @if (discount() && discount()! > 0) { + + -{{ discount() }}% + + } +
+ +
+

{{ title() }}

+ +
+ + {{ formattedDiscountedPrice() }} + + + @if (discount() && discount()! > 0) { + + {{ formattedOriginalPrice() }} + + } +
+ +
+ {{ + formattedTransferPrice() + }} + con transferencia +
+ +
+ + {{ buttonText() }} + +
+
+
diff --git a/src/app/shared/components/product-card/product-card.component.scss b/src/app/shared/components/product-column-with-image/product-column-with-image.component.scss similarity index 90% rename from src/app/shared/components/product-card/product-card.component.scss rename to src/app/shared/components/product-column-with-image/product-column-with-image.component.scss index 73a8795..c2622ef 100644 --- a/src/app/shared/components/product-card/product-card.component.scss +++ b/src/app/shared/components/product-column-with-image/product-column-with-image.component.scss @@ -4,10 +4,12 @@ height: 100%; } -.product-card { +.product-column-with-image { width: 100%; max-height: 411px; - transition: transform 0.2s ease, box-shadow 0.2s ease; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; &:hover { transform: translateY(-2px); @@ -41,11 +43,10 @@ font-weight: 700; } - &__price-original { font-size: 11px; font-weight: 325; - color: #DDDDDD; + color: #dddddd; } &__price-transfer { diff --git a/src/app/shared/components/product-column-with-image/product-column-with-image.component.ts b/src/app/shared/components/product-column-with-image/product-column-with-image.component.ts new file mode 100644 index 0000000..4196dc3 --- /dev/null +++ b/src/app/shared/components/product-column-with-image/product-column-with-image.component.ts @@ -0,0 +1,48 @@ +import { NgOptimizedImage } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; + +import { ButtonComponent } from '../button/button.component'; + +@Component({ + selector: 'app-product-column-with-image', + imports: [ButtonComponent, NgOptimizedImage], + templateUrl: './product-column-with-image.component.html', + styleUrl: './product-column-with-image.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProductColumnWithImageComponent { + readonly imageUrl = input(null); + readonly title = input(''); + readonly originalPrice = input(0); + readonly discount = input(null); + readonly transferPrice = input(null); + readonly buttonText = input('Comprar'); + readonly imagePriority = input(false); + + readonly buy = output(); + + readonly discountedPrice = computed(() => { + const original = this.originalPrice(); + const discount = this.discount(); + + return !discount || discount <= 0 ? original : original * (1 - discount / 100); + }); + + readonly computedTransferPrice = computed(() => { + return this.transferPrice() ?? this.discountedPrice(); + }); + + readonly formattedDiscountedPrice = computed(() => this.formatCurrency(this.discountedPrice())); + readonly formattedOriginalPrice = computed(() => this.formatCurrency(this.originalPrice())); + readonly formattedTransferPrice = computed(() => + this.formatCurrency(this.computedTransferPrice()), + ); + + 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(',')}`; + } +} diff --git a/src/app/shared/components/product-list/product-list.component.html b/src/app/shared/components/product-list/product-list.component.html new file mode 100644 index 0000000..77ccb6d --- /dev/null +++ b/src/app/shared/components/product-list/product-list.component.html @@ -0,0 +1,40 @@ +
+ @for (item of items(); track item.id; let index = $index) { +
+ @switch (layout()) { + @case ('row') { + + } + @case ('column_with_cart') { + + } + @default { + + } + } +
+ } +
diff --git a/src/app/shared/components/product-list/product-list.component.scss b/src/app/shared/components/product-list/product-list.component.scss new file mode 100644 index 0000000..26febf5 --- /dev/null +++ b/src/app/shared/components/product-list/product-list.component.scss @@ -0,0 +1,27 @@ +:host { + display: block; + width: 100%; +} + +.product-list { + display: grid; + width: 100%; + gap: 1.5rem; + + &--row { + grid-template-columns: minmax(0, 1fr); + + .product-list__item { + width: 100%; + } + } + + &--column { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); + align-items: stretch; + } + + &__item { + min-width: 0; + } +} diff --git a/src/app/shared/components/product-list/product-list.component.spec.ts b/src/app/shared/components/product-list/product-list.component.spec.ts new file mode 100644 index 0000000..f1a5031 --- /dev/null +++ b/src/app/shared/components/product-list/product-list.component.spec.ts @@ -0,0 +1,73 @@ +import { TestBed, getTestBed } from '@angular/core/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { ProductListComponent, ProductListItem, ProductListLayout } from './product-list.component'; + +describe('ProductListComponent', () => { + const items: ProductListItem[] = [ + { + id: 1, + nombre: 'Producto uno', + descripcion: 'Primera descripcion', + precio: '100.00', + image: '/images/one.png', + variants: [], + }, + { + id: 2, + nombre: 'Producto dos', + descripcion: 'Segunda descripcion', + precio: 200, + image: null, + variants: [], + }, + ]; + + beforeAll(() => { + try { + getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); + } catch { + // Test environment may already be initialized by another setup entrypoint. + } + }); + + afterEach(() => TestBed.resetTestingModule()); + + async function render(layout: ProductListLayout) { + await TestBed.configureTestingModule({ imports: [ProductListComponent] }).compileComponents(); + const fixture = TestBed.createComponent(ProductListComponent); + fixture.componentRef.setInput('layout', layout); + fixture.componentRef.setInput('items', items); + fixture.detectChanges(); + + return fixture; + } + + it('renders every row product at full available width', async () => { + const fixture = await render('row'); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('.product-list--row')).not.toBeNull(); + expect(element.querySelectorAll('app-product-row-card')).toHaveLength(2); + expect(element.querySelector('app-product-column-with-image')).toBeNull(); + }); + + it('renders image products next to each other in the column grid', async () => { + const fixture = await render('column_with_image'); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('.product-list--column')).not.toBeNull(); + expect(element.querySelectorAll('app-product-column-with-image')).toHaveLength(2); + }); + + it('renders cart products next to each other in the column grid', async () => { + const fixture = await render('column_with_cart'); + + expect( + (fixture.nativeElement as HTMLElement).querySelectorAll( + 'app-product-vertical-with-cart-card', + ), + ).toHaveLength(2); + }); +}); diff --git a/src/app/shared/components/product-list/product-list.component.ts b/src/app/shared/components/product-list/product-list.component.ts new file mode 100644 index 0000000..8e0b399 --- /dev/null +++ b/src/app/shared/components/product-list/product-list.component.ts @@ -0,0 +1,79 @@ +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; + +import { ProductColumnWithImageComponent } from '../product-column-with-image/product-column-with-image.component'; +import { + ProductRowCardComponent, + Variant as RowVariant, +} from '../product-row-card/product-row-card.component'; +import { ProductVerticalWithCartCardComponent } from '../product-vertical-with-cart-card/product-vertical-with-cart-card.component'; + +export type ProductListLayout = 'row' | 'column_with_image' | 'column_with_cart'; + +export interface ProductListVariant { + id: number; + stock_tecnico: number | null; + values: Record; +} + +export interface ProductListItem { + id: number; + nombre: string; + descripcion?: string | null; + precio: number | string; + image?: string | null; + stock_tecnico?: number | null; + variants?: ProductListVariant[]; +} + +export interface ProductListCartEvent { + product: ProductListItem; + quantity: number; + variant?: number | null; +} + +@Component({ + selector: 'app-product-list', + imports: [ + ProductColumnWithImageComponent, + ProductRowCardComponent, + ProductVerticalWithCartCardComponent, + ], + templateUrl: './product-list.component.html', + styleUrl: './product-list.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProductListComponent { + readonly layout = input.required(); + readonly items = input.required(); + + readonly buy = output(); + readonly addToCart = output(); + + protected price(item: ProductListItem): number { + const price = Number(item.precio); + + return Number.isFinite(price) ? price : 0; + } + + protected variantsFor(item: ProductListItem): RowVariant[] { + return (item.variants ?? []).map((variant) => ({ + value: variant.id, + label: Object.values(variant.values).join(' / ') || `Variante ${variant.id}`, + })); + } + + protected emitRowCart( + product: ProductListItem, + event: { quantity: number; variant: unknown }, + ): void { + this.addToCart.emit({ + product, + quantity: event.quantity, + variant: typeof event.variant === 'number' ? event.variant : null, + }); + } + + protected emitColumnCart(product: ProductListItem, event: { quantity: number }): void { + this.addToCart.emit({ product, quantity: event.quantity }); + } +}