feat(product-row-card): add reusable product row card component with styling and functionality

This commit is contained in:
2026-07-17 11:54:12 -03:00
parent 45c2ee7a2f
commit 40cc2b840d
5 changed files with 201 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(',')}`;
}
}