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">
|
<section class="component-demo card shadow-sm mt-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<span class="eyebrow">Reusable Component</span>
|
<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">
|
<p class="text-muted mb-4">
|
||||||
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se
|
Las tarjetas se adaptan al tamano del contenedor padre. A continuacion se
|
||||||
@@ -617,7 +617,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
@for (product of testProducts; track product.title) {
|
@for (product of testProducts; track product.title) {
|
||||||
<div class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch">
|
<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"
|
[imageUrl]="product.imageUrl"
|
||||||
[title]="product.title"
|
[title]="product.title"
|
||||||
[originalPrice]="product.originalPrice"
|
[originalPrice]="product.originalPrice"
|
||||||
@@ -687,7 +687,7 @@
|
|||||||
<div
|
<div
|
||||||
class="col-12 col-md-6 col-lg-4 mb-4 d-flex align-items-stretch store-section-demo__item"
|
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"
|
[imageUrl]="product.imageUrl"
|
||||||
[title]="product.title"
|
[title]="product.title"
|
||||||
[originalPrice]="product.originalPrice"
|
[originalPrice]="product.originalPrice"
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.store-section-demo__item {
|
.store-section-demo__item {
|
||||||
app-product-card {
|
app-product-column-with-image {
|
||||||
width: 100%;
|
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 { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component';
|
||||||
import { InputComponent } from '../../../../shared/components/input/input.component';
|
import { InputComponent } from '../../../../shared/components/input/input.component';
|
||||||
import { PaginatorComponent } from '../../../../shared/components/paginator/paginator.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 { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component';
|
||||||
import {
|
import {
|
||||||
ProductVerticalWithCartCardComponent
|
ProductVerticalWithCartCardComponent
|
||||||
@@ -27,7 +27,7 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
|
|||||||
CartComponent,
|
CartComponent,
|
||||||
InputComponent,
|
InputComponent,
|
||||||
PaginatorComponent,
|
PaginatorComponent,
|
||||||
ProductCardComponent,
|
ProductColumnWithImageComponent,
|
||||||
ProductRowCardComponent,
|
ProductRowCardComponent,
|
||||||
ProductVerticalWithCartCardComponent,
|
ProductVerticalWithCartCardComponent,
|
||||||
StoreSectionComponent,
|
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">
|
<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) {
|
@for (product of productCards(); track product.id; let index = $index) {
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<app-product-card
|
<app-product-column-with-image
|
||||||
[title]="product.title"
|
[title]="product.title"
|
||||||
[originalPrice]="product.originalPrice"
|
[originalPrice]="product.originalPrice"
|
||||||
[discount]="product.discount"
|
[discount]="product.discount"
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ describe('StoreHomePageComponent', () => {
|
|||||||
|
|
||||||
expect(catalogServiceStub.getProductos).not.toHaveBeenCalled();
|
expect(catalogServiceStub.getProductos).not.toHaveBeenCalled();
|
||||||
expect(element.querySelector('.store-section__title')?.textContent?.trim()).toBe('Productos');
|
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('Auriculares Bluetooth');
|
||||||
expect(element.textContent).toContain('Teclado Mecanico');
|
expect(element.textContent).toContain('Teclado Mecanico');
|
||||||
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
|
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
|
||||||
@@ -267,7 +267,7 @@ describe('StoreHomePageComponent', () => {
|
|||||||
const element = fixture.nativeElement as HTMLElement;
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
expect(element.textContent).toContain('No pudimos cargar los productos en este momento.');
|
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();
|
expect(catalogServiceStub.getProductos).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Subscription } from 'rxjs';
|
|||||||
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
|
import { ApiPaginatedResponse } from '../../../../core/services/api-paginated-response.interface';
|
||||||
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
import { CatalogService } from '../../../../core/services/catalog/catalog.service';
|
||||||
import { Product } from '../../../../core/services/catalog/catalog.interface';
|
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 { PaginatorComponent } from '../../../../shared/components/paginator/paginator.component';
|
||||||
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
|
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
|
||||||
import { HeroBannerComponent } from '../../../../shared/components/hero-banner/hero-banner.component';
|
import { HeroBannerComponent } from '../../../../shared/components/hero-banner/hero-banner.component';
|
||||||
@@ -34,7 +34,12 @@ interface StoreHomeProductCardViewModel {
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-store-home-page',
|
selector: 'app-store-home-page',
|
||||||
imports: [StoreSectionComponent, ProductCardComponent, PaginatorComponent, HeroBannerComponent],
|
imports: [
|
||||||
|
StoreSectionComponent,
|
||||||
|
ProductColumnWithImageComponent,
|
||||||
|
PaginatorComponent,
|
||||||
|
HeroBannerComponent,
|
||||||
|
],
|
||||||
templateUrl: './store-home-page.component.html',
|
templateUrl: './store-home-page.component.html',
|
||||||
styleUrl: './store-home-page.component.scss',
|
styleUrl: './store-home-page.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
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%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-card {
|
.product-column-with-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 411px;
|
max-height: 411px;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
@@ -41,11 +43,10 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
&__price-original {
|
&__price-original {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 325;
|
font-weight: 325;
|
||||||
color: #DDDDDD;
|
color: #dddddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__price-transfer {
|
&__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