feat: implement ProductCard component and configure local build settings in angular.json
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
|
||||
BIN
public/images/pantalon-scuba.png
Normal file
BIN
public/images/pantalon-scuba.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 647 KiB |
BIN
public/images/zapatillas-running.png
Normal file
BIN
public/images/zapatillas-running.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 541 KiB |
@@ -256,4 +256,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
<p class="text-muted mb-4">
|
||||
Las tarjetas se adaptan al tamaño del contenedor padre. A continuación se presentan dentro de una grilla de Bootstrap.
|
||||
</p>
|
||||
|
||||
<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
|
||||
[imageUrl]="product.imageUrl"
|
||||
[title]="product.title"
|
||||
[originalPrice]="product.originalPrice"
|
||||
[discount]="product.discount"
|
||||
[transferPrice]="product.transferPrice"
|
||||
(buy)="onProductBuy(product.title)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { ButtonComponent } from '../../../../shared/components/button/button.component';
|
||||
import { InputComponent } from '../../../../shared/components/input/input.component';
|
||||
import { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
|
||||
import { TenantService } from '../../../../core/services/tenant.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-reutilizables-test-page',
|
||||
imports: [ButtonComponent, InputComponent],
|
||||
imports: [ButtonComponent, InputComponent, ProductCardComponent],
|
||||
templateUrl: './reutilizables-test-page.component.html',
|
||||
styleUrl: './reutilizables-test-page.component.scss'
|
||||
})
|
||||
@@ -28,6 +29,35 @@ export class ReutilizablesTestPageComponent {
|
||||
protected clearTrigger = 0;
|
||||
protected editableDisabled = true;
|
||||
|
||||
protected readonly testProducts = [
|
||||
{
|
||||
title: 'Pantalón recto de scuba negro',
|
||||
originalPrice: 95000,
|
||||
discount: 20,
|
||||
transferPrice: 74800,
|
||||
imageUrl: '/images/pantalon-scuba.png'
|
||||
},
|
||||
{
|
||||
title: 'Zapatillas de running azul',
|
||||
originalPrice: 120000,
|
||||
discount: 15,
|
||||
transferPrice: 98000,
|
||||
imageUrl: '/images/zapatillas-running.png'
|
||||
},
|
||||
{
|
||||
title: 'Remera básica blanca (Sin Descuento)',
|
||||
originalPrice: 32000,
|
||||
discount: null,
|
||||
transferPrice: 30000,
|
||||
imageUrl: null
|
||||
}
|
||||
];
|
||||
|
||||
protected onProductBuy(productTitle: string): void {
|
||||
console.log(`Comprando: ${productTitle}`);
|
||||
alert(`Comprando: ${productTitle}`);
|
||||
}
|
||||
|
||||
protected onFileChange(file: File | null): void {
|
||||
this.fileName = file?.name ?? '';
|
||||
}
|
||||
@@ -37,3 +67,4 @@ export class ReutilizablesTestPageComponent {
|
||||
this.clearTrigger += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<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()) {
|
||||
<img [src]="imageUrl()" [alt]="title()" 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-3">{{ title() }}</h3>
|
||||
|
||||
<!-- Prices Area -->
|
||||
<div class="product-card__prices d-flex align-items-center justify-content-center gap-2 mb-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>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
&__image-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__discount-badge {
|
||||
background-color: var(--tenant-primary) !important;
|
||||
font-size: 13px;
|
||||
font-weight: 325;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 12px;
|
||||
font-weight: 325;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__price-discounted {
|
||||
color: var(--tenant-primary) !important;
|
||||
font-size: 24px;
|
||||
font-weight: 425;
|
||||
}
|
||||
|
||||
|
||||
&__price-original {
|
||||
font-size: 11px;
|
||||
font-weight: 325;
|
||||
color: #DDDDDD;
|
||||
}
|
||||
|
||||
&__price-transfer {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
&__price-transfer-value {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__price-transfer-label {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
&__action {
|
||||
app-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
::ng-deep .btn {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
import { ButtonComponent } from '../button/button.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-card',
|
||||
standalone: true,
|
||||
imports: [ButtonComponent],
|
||||
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');
|
||||
|
||||
// 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(',')}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user