feat(product-list): replace ProductCard with ProductColumnWithImage and update related components

This commit is contained in:
2026-07-20 14:27:24 -03:00
parent 625990f594
commit c0eb3369c5
15 changed files with 349 additions and 153 deletions

View File

@@ -607,7 +607,7 @@
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-4">Tarjetas de Producto (ProductCard)</h2>
<h2 class="mb-4">Tarjetas de Producto (Column With Image)</h2>
<p class="text-muted mb-4">
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se
@@ -617,7 +617,7 @@
<div class="row">
@for (product of testProducts; track product.title) {
<div class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch">
<app-product-card
<app-product-column-with-image
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
@@ -687,7 +687,7 @@
<div
class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item"
>
<app-product-card
<app-product-column-with-image
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"

View File

@@ -130,7 +130,7 @@ h1 {
}
.store-section-demo__item {
app-product-card {
app-product-column-with-image {
width: 100%;
}
}

View File

@@ -8,7 +8,7 @@ import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-
import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
import { InputComponent } from '../../../../shared/components/input/input.component';
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
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 { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component';
import {
ProductVerticalWithCartCardComponent
@@ -27,7 +27,7 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
CartComponent,
InputComponent,
PaginatorComponent,
ProductCardComponent,
ProductColumnWithImageComponent,
ProductRowCardComponent,
ProductVerticalWithCartCardComponent,
StoreSectionComponent,
@@ -280,4 +280,3 @@ export class ReutilizablesTestPageComponent {
}
}

View File

@@ -19,7 +19,7 @@
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-4 g-4">
@for (product of productCards(); track product.id; let index = $index) {
<div class="col">
<app-product-card
<app-product-column-with-image
[title]="product.title"
[originalPrice]="product.originalPrice"
[discount]="product.discount"

View File

@@ -113,7 +113,7 @@ describe('StoreHomePageComponent', () => {
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();
});
});

View File

@@ -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,

View File

@@ -1,66 +0,0 @@
<div
class="product-card border rounded shadow-sm bg-white overflow-hidden d-flex flex-column h-100"
>
<!-- Image section -->
<div class="product-card__image-container position-relative bg-light">
@if (imageUrl(); as imageSrc) {
<img
[ngSrc]="imageSrc"
[alt]="title()"
[priority]="imagePriority()"
fill
sizes="(min-width: 1200px) 25vw, (min-width: 768px) 50vw, 100vw"
class="product-card__image w-100 h-100 object-fit-cover"
/>
} @else {
<div
class="product-card__image-placeholder w-100 h-100 d-flex align-items-center justify-content-center bg-light text-muted"
>
<i class="fa-solid fa-image fa-2x"></i>
</div>
}
<!-- Discount badge in top right -->
@if (discount() && discount()! > 0) {
<span
class="product-card__discount-badge position-absolute top-0 end-0 bg-primary text-white px-2 py-1"
>
-{{ discount() }}%
</span>
}
</div>
<!-- Content section -->
<div class="product-card__body p-3 d-flex flex-column align-items-center text-center flex-grow-1">
<!-- Product Title -->
<h3 class="product-card__title text-uppercase text-black mb-4">{{ title() }}</h3>
<!-- Prices Area -->
<div class="product-card__prices d-flex align-items-center justify-content-center gap-2">
<!-- Discounted (computado) Price -->
<span class="product-card__price-discounted text-primary">{{
formattedDiscountedPrice()
}}</span>
<!-- Original strikethrough Price (only if discount exists) -->
@if (discount() && discount()! > 0) {
<span class="product-card__price-original text-decoration-line-through">{{
formattedOriginalPrice()
}}</span>
}
</div>
<!-- Transfer Price Info -->
<div class="product-card__price-transfer mb-4">
<span class="product-card__price-transfer-value">{{ formattedTransferPrice() }}</span>
<span class="product-card__price-transfer-label"> con transferencia</span>
</div>
<!-- Purchase Action Button -->
<div class="product-card__action mt-auto w-100">
<app-button variant="primary" class="w-100" (click)="buy.emit()">
{{ buttonText() }}
</app-button>
</div>
</div>
</div>

View File

@@ -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<string | null>(null);
readonly title = input<string>('');
readonly originalPrice = input<number>(0);
readonly discount = input<number | null>(null);
readonly transferPrice = input<number | null>(null);
readonly buttonText = input<string>('Comprar');
readonly imagePriority = input<boolean>(false);
// Interactive events
readonly buy = output<void>();
// 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(',')}`;
}
}

View File

@@ -0,0 +1,61 @@
<article
class="product-column-with-image border rounded shadow-sm bg-white overflow-hidden d-flex flex-column h-100"
>
<div class="product-column-with-image__image-container position-relative bg-light">
@if (imageUrl(); as imageSrc) {
<img
[ngSrc]="imageSrc"
[alt]="title()"
[priority]="imagePriority()"
fill
sizes="(min-width: 1200px) 25vw, (min-width: 768px) 50vw, 100vw"
class="product-column-with-image__image w-100 h-100 object-fit-cover"
/>
} @else {
<div
class="product-column-with-image__image-placeholder w-100 h-100 d-flex align-items-center justify-content-center bg-light text-muted"
>
<i class="fa-solid fa-image fa-2x"></i>
</div>
}
@if (discount() && discount()! > 0) {
<span
class="product-column-with-image__discount-badge position-absolute top-0 end-0 bg-primary text-white px-2 py-1"
>
-{{ discount() }}%
</span>
}
</div>
<div
class="product-column-with-image__body p-3 d-flex flex-column align-items-center text-center flex-grow-1"
>
<h3 class="product-column-with-image__title text-uppercase text-black mb-4">{{ title() }}</h3>
<div class="product-column-with-image__prices d-flex align-items-center justify-content-center gap-2">
<span class="product-column-with-image__price-discounted text-primary">
{{ formattedDiscountedPrice() }}
</span>
@if (discount() && discount()! > 0) {
<span class="product-column-with-image__price-original text-decoration-line-through">
{{ formattedOriginalPrice() }}
</span>
}
</div>
<div class="product-column-with-image__price-transfer mb-4">
<span class="product-column-with-image__price-transfer-value">{{
formattedTransferPrice()
}}</span>
<span class="product-column-with-image__price-transfer-label"> con transferencia</span>
</div>
<div class="product-column-with-image__action mt-auto w-100">
<app-button variant="primary" class="w-100" (click)="buy.emit()">
{{ buttonText() }}
</app-button>
</div>
</div>
</article>

View File

@@ -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 {

View File

@@ -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<string | null>(null);
readonly title = input<string>('');
readonly originalPrice = input<number>(0);
readonly discount = input<number | null>(null);
readonly transferPrice = input<number | null>(null);
readonly buttonText = input<string>('Comprar');
readonly imagePriority = input<boolean>(false);
readonly buy = output<void>();
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(',')}`;
}
}

View File

@@ -0,0 +1,40 @@
<div
class="product-list"
[class.product-list--row]="layout() === 'row'"
[class.product-list--column]="layout() !== 'row'"
>
@for (item of items(); track item.id; let index = $index) {
<div class="product-list__item">
@switch (layout()) {
@case ('row') {
<app-product-row-card
[title]="item.nombre"
[description]="item.descripcion ?? ''"
[price]="price(item)"
[variants]="variantsFor(item)"
(buy)="buy.emit(item)"
(addToCart)="emitRowCart(item, $event)"
/>
}
@case ('column_with_cart') {
<app-product-vertical-with-cart-card
[title]="item.nombre"
[description]="item.descripcion ?? ''"
[price]="price(item)"
(buy)="buy.emit(item)"
(addToCart)="emitColumnCart(item, $event)"
/>
}
@default {
<app-product-column-with-image
[imageUrl]="item.image ?? null"
[title]="item.nombre"
[originalPrice]="price(item)"
[imagePriority]="index < 4"
(buy)="buy.emit(item)"
/>
}
}
</div>
}
</div>

View File

@@ -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;
}
}

View File

@@ -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);
});
});

View File

@@ -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<string, string>;
}
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<ProductListLayout>();
readonly items = input.required<readonly ProductListItem[]>();
readonly buy = output<ProductListItem>();
readonly addToCart = output<ProductListCartEvent>();
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 });
}
}