feat(product-row-card): add reusable product row card component with styling and functionality
This commit is contained in:
@@ -631,6 +631,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 (Row Layout)</h2>
|
||||||
|
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Diseño horizontal que ocupa el 100% del ancho disponible.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 mb-4 d-flex align-items-stretch">
|
||||||
|
<app-product-row-card
|
||||||
|
[title]="testRowProduct.title"
|
||||||
|
[description]="testRowProduct.description"
|
||||||
|
[price]="testRowProduct.price"
|
||||||
|
[variants]="testRowProduct.variants"
|
||||||
|
(buy)="onProductBuy(testRowProduct.title)"
|
||||||
|
(addToCart)="triggerToast('success')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<app-store-section
|
<app-store-section
|
||||||
title="PRODUCTOS"
|
title="PRODUCTOS"
|
||||||
class="component-demo store-section-demo mt-5"
|
class="component-demo store-section-demo mt-5"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { IconButtonComponent } from '../../../../shared/components/icon-button/i
|
|||||||
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 { ProductCardComponent } from '../../../../shared/components/product-card/product-card.component';
|
||||||
|
import { ProductRowCardComponent } from '../../../../shared/components/product-row-card/product-row-card.component';
|
||||||
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
|
import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component';
|
||||||
import { ModalService } from '../../../../core/services/modal.service';
|
import { ModalService } from '../../../../core/services/modal.service';
|
||||||
import { TenantService } from '../../../../core/services/tenant.service';
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
@@ -24,6 +25,7 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
|
|||||||
InputComponent,
|
InputComponent,
|
||||||
PaginatorComponent,
|
PaginatorComponent,
|
||||||
ProductCardComponent,
|
ProductCardComponent,
|
||||||
|
ProductRowCardComponent,
|
||||||
StoreSectionComponent,
|
StoreSectionComponent,
|
||||||
IconButtonComponent,
|
IconButtonComponent,
|
||||||
CartIconComponent,
|
CartIconComponent,
|
||||||
@@ -86,6 +88,16 @@ export class ReutilizablesTestPageComponent {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected readonly testRowProduct = {
|
||||||
|
title: 'ENTRADA GENERAL',
|
||||||
|
description: 'Acceso total al predio. No incluye acceso a estacionamiento. Niños menores de 5 años ingresan gratis.',
|
||||||
|
price: 10000,
|
||||||
|
variants: [
|
||||||
|
{ label: '10 de Octubre', value: '10-oct' },
|
||||||
|
{ label: '11 de Octubre', value: '11-oct' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
protected readonly cartMockItems: CartItemMock[] = [
|
protected readonly cartMockItems: CartItemMock[] = [
|
||||||
{
|
{
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<div class="product-row-card border rounded bg-white shadow-sm d-flex justify-content-between p-3 gap-3">
|
||||||
|
<!-- Left Side: Title and Description -->
|
||||||
|
<div class="product-row-card__info d-flex flex-column justify-content-center flex-grow-1">
|
||||||
|
<h3 class="product-row-card__title text-uppercase text-black mb-1 m-0">
|
||||||
|
{{ title() }}
|
||||||
|
</h3>
|
||||||
|
@if (description()) {
|
||||||
|
<p class="product-row-card__description m-0 mt-1">
|
||||||
|
{{ description() }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Side: Actions and Pricing -->
|
||||||
|
<div class="product-row-card__actions d-flex flex-column gap-2 justify-content-center">
|
||||||
|
<!-- Top Row: Price and Comprar button -->
|
||||||
|
<div class="d-flex align-items-center justify-content-end gap-3">
|
||||||
|
<span class="product-row-card__price text-primary">
|
||||||
|
{{ formattedPrice() }}
|
||||||
|
</span>
|
||||||
|
<div class="product-row-card__btn-wrapper">
|
||||||
|
<app-button variant="primary" (click)="buy.emit()">
|
||||||
|
Comprar
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Row: Select, Quantity, and Agregar al carrito button -->
|
||||||
|
<div class="d-flex align-items-center justify-content-end gap-2">
|
||||||
|
@if (variants().length > 0) {
|
||||||
|
<select class="form-select product-row-card__select" [(ngModel)]="selectedVariant">
|
||||||
|
@for (variant of variants(); track variant.value) {
|
||||||
|
<option [value]="variant.value">{{ variant.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-quantity-selector [(quantity)]="quantity" ></app-quantity-selector>
|
||||||
|
|
||||||
|
<div class="product-row-card__btn-wrapper">
|
||||||
|
<app-button variant="secondary" (click)="onAddToCart()">
|
||||||
|
Agregar al carrito
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-row-card {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #f8f9fa; /* slightly light bg matching the screenshot if needed, but let's use white or #f8f9fa based on the border */
|
||||||
|
border-color: #e9ecef !important;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #555; /* dark grey matching screenshot */
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #777; /* lighter grey for text */
|
||||||
|
line-height: 1.4;
|
||||||
|
max-width: 600px;
|
||||||
|
|
||||||
|
/* Simulate the bold text for 'Niños menores...' if it was HTML.
|
||||||
|
Since it's passed as string we just let it be, unless we parse it. */
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--tenant-primary, #009933) !important; /* Green matching screenshot */
|
||||||
|
}
|
||||||
|
|
||||||
|
&__select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 150px;
|
||||||
|
font-size: 14px;
|
||||||
|
height: 38px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #ccc;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--tenant-primary);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn-wrapper {
|
||||||
|
min-width: 160px; /* To make both buttons equal width as in screenshot */
|
||||||
|
|
||||||
|
app-button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
::ng-deep .btn {
|
||||||
|
width: 100%;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, input, output, model } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ButtonComponent } from '../button/button.component';
|
||||||
|
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
|
||||||
|
|
||||||
|
export interface Variant {
|
||||||
|
label: string;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-product-row-card',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ButtonComponent, QuantitySelectorComponent, FormsModule],
|
||||||
|
templateUrl: './product-row-card.component.html',
|
||||||
|
styleUrl: './product-row-card.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ProductRowCardComponent {
|
||||||
|
// Configurable inputs
|
||||||
|
readonly title = input<string>('');
|
||||||
|
readonly description = input<string>('');
|
||||||
|
readonly price = input<number>(0);
|
||||||
|
readonly variants = input<Variant[]>([]);
|
||||||
|
|
||||||
|
// Internal state models
|
||||||
|
readonly quantity = model<number>(1);
|
||||||
|
readonly selectedVariant = model<any>(null);
|
||||||
|
|
||||||
|
// Interactive events
|
||||||
|
readonly buy = output<void>();
|
||||||
|
readonly addToCart = output<{ quantity: number; variant: any }>();
|
||||||
|
|
||||||
|
// Formatted string for price: "$ 10.000"
|
||||||
|
readonly formattedPrice = computed(() => {
|
||||||
|
return this.formatCurrency(this.price());
|
||||||
|
});
|
||||||
|
|
||||||
|
protected onAddToCart(): void {
|
||||||
|
this.addToCart.emit({
|
||||||
|
quantity: this.quantity(),
|
||||||
|
variant: this.selectedVariant()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to format currency numbers to Argentine Pesos style "$ XX.XXX".
|
||||||
|
*/
|
||||||
|
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