feat(product-list): replace ProductCard with ProductColumnWithImage and update related components
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -130,7 +130,7 @@ h1 {
|
||||
}
|
||||
|
||||
.store-section-demo__item {
|
||||
app-product-card {
|
||||
app-product-column-with-image {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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(',')}`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
@@ -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(',')}`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user