feat: enhance product vertical card with variant selection and improve store header responsiveness

This commit is contained in:
2026-08-05 16:48:34 -03:00
parent 5da341dde2
commit c9f9e6cc3e
10 changed files with 362 additions and 31 deletions

View File

@@ -2,7 +2,7 @@
<div class="store-layout__header-main">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4">
<div class="row align-items-center gy-3 gy-md-0">
<div class="col-auto col-md-auto order-1">
<div class="col-auto col-md-auto order-1 store-layout__brand-column">
<a
routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
@@ -24,7 +24,7 @@
</div>
<div class="col-12 col-md order-4 order-md-2">
<div class="store-layout__search-wrapper w-100 ms-md-auto me-md-3">
<div class="store-layout__search-wrapper ms-md-auto">
<div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input
@@ -58,7 +58,7 @@
</div>
</div>
<div class="col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3">
<div class="store-layout__actions-column col-auto col-md-auto order-2 order-md-3 ms-auto d-flex flex-row align-items-center justify-content-end gap-2 gap-md-3">
@if (ticketsMenu(); as menu) {
<app-button
variant="primary"

View File

@@ -12,6 +12,10 @@
min-width: 150px;
}
.store-layout__brand-column {
max-width: 12rem;
}
.store-layout__brand-logo-shell {
position: relative;
width: min(100%, 12rem);
@@ -49,6 +53,15 @@
.store-layout__search-wrapper {
width: min(100%, 260px);
}
.store-layout__actions-column {
padding-left: 0;
}
::ng-deep .store-layout__tickets-btn-host {
flex: 0 0 clamp(8rem, 15vw, 13.5rem);
width: clamp(8rem, 15vw, 13.5rem);
}
}
.store-layout__search {
@@ -145,13 +158,6 @@
max-width: 150px;
}
.store-layout__tickets-btn-host {
::ng-deep .btn {
min-width: 0 !important;
padding-inline: 1rem;
}
}
.store-layout__mobile-separator {
border-color: #DDDDDD;
opacity: 1;
@@ -159,6 +165,34 @@
}
}
@media (max-width: 399.98px) {
.store-layout__brand-column {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: none;
padding-right: 0.25rem;
}
.store-layout__brand-slot {
width: 100%;
max-width: 6.5rem;
}
.store-layout__actions-column {
flex: 0 0 auto;
width: auto;
margin-left: 0 !important;
padding-left: 0.25rem;
gap: 0.25rem !important;
}
::ng-deep .store-layout__tickets-btn-host .btn {
padding-inline: 0.75rem;
font-size: 0.875rem;
}
}
.store-layout__mobile-dropdown-menu {
top: 100%;
min-width: 220px;

View File

@@ -22,6 +22,7 @@
[title]="item.nombre"
[description]="item.descripcion ?? ''"
[price]="price(item)"
[variants]="item.variants ?? []"
(buy)="emitColumnBuy(item, $event)"
(addToCart)="emitColumnCart(item, $event)"
/>

View File

@@ -160,6 +160,31 @@ describe('ProductListComponent', () => {
});
});
it('passes column variants through to the purchase event', async () => {
const itemWithVariants: ProductListItem = {
...items[0],
variants: [
{ id: 91, stock_tecnico: 3, values: { fecha: '10 de octubre' } },
{ id: 92, stock_tecnico: 4, values: { fecha: '11 de octubre' } },
],
};
const fixture = await render('column_with_cart', [itemWithVariants]);
const buySpy = vi.fn();
fixture.componentInstance.buy.subscribe(buySpy);
const card = fixture.nativeElement.querySelector(
'app-product-vertical-with-cart-card',
) as HTMLElement;
(card.querySelector('.btn-primary') as HTMLButtonElement).click();
expect(buySpy).toHaveBeenCalledWith({
product: itemWithVariants,
quantity: 1,
variant: 91,
directPurchase: true,
});
});
it('renders pagination and emits the requested page', async () => {
const fixture = await render('row');
const pageChangeSpy = vi.fn();

View File

@@ -123,8 +123,11 @@ export class ProductListComponent {
});
}
protected emitColumnCart(product: ProductListItem, event: { quantity: number }): void {
this.addToCart.emit({ product, quantity: event.quantity });
protected emitColumnCart(
product: ProductListItem,
event: { quantity: number; variant: number | null },
): void {
this.addToCart.emit({ product, quantity: event.quantity, variant: event.variant });
}
protected emitRowBuy(
@@ -139,8 +142,16 @@ export class ProductListComponent {
});
}
protected emitColumnBuy(product: ProductListItem, event: { quantity: number }): void {
this.buy.emit({ product, quantity: event.quantity, variant: null, directPurchase: true });
protected emitColumnBuy(
product: ProductListItem,
event: { quantity: number; variant: number | null },
): void {
this.buy.emit({
product,
quantity: event.quantity,
variant: event.variant,
directPurchase: true,
});
}
protected emitProductDetailBuy(product: ProductListItem): void {

View File

@@ -8,14 +8,71 @@
</div>
<div class="product-vertical-with-cart-card__purchase">
<div class="product-vertical-with-cart-card__summary">
<span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span>
<app-quantity-selector [(quantity)]="quantity" />
</div>
@if (!hasVariants()) {
<div class="product-vertical-with-cart-card__summary">
<span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span>
<app-quantity-selector [(quantity)]="quantity" />
</div>
} @else if (!hasMultipleVariantSelectors()) {
<div class="product-vertical-with-cart-card__single-variant">
<span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span>
<div class="product-vertical-with-cart-card__single-variant-row">
@for (selector of variantSelectors(); track selector.key) {
<select
class="form-select product-vertical-with-cart-card__variant-select"
[attr.aria-label]="selector.label"
[ngModel]="selectedValues()[selector.key]"
(ngModelChange)="onVariantValueChange(selector.key, $event)"
>
@for (option of selector.options; track option) {
<option [ngValue]="option">{{ option }}</option>
}
</select>
}
<app-quantity-selector [(quantity)]="quantity" />
</div>
</div>
} @else {
<div class="product-vertical-with-cart-card__multiple-variants">
<div class="product-vertical-with-cart-card__summary-column">
<span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span>
<app-quantity-selector [(quantity)]="quantity" />
</div>
<div class="product-vertical-with-cart-card__variant-selectors">
@for (selector of variantSelectors(); track selector.key) {
<select
class="form-select product-vertical-with-cart-card__variant-select"
[attr.aria-label]="selector.label"
[ngModel]="selectedValues()[selector.key]"
(ngModelChange)="onVariantValueChange(selector.key, $event)"
>
@for (option of selector.options; track option) {
<option [ngValue]="option">{{ option }}</option>
}
</select>
}
</div>
</div>
}
<div class="product-vertical-with-cart-card__actions">
<app-button variant="primary" (click)="onBuy()">Comprar</app-button>
<app-button variant="secondary" (click)="onAddToCart()"> Agregar al carrito </app-button>
<app-button
variant="primary"
[disabled]="hasVariants() && selectedVariant() === null"
(click)="onBuy()"
>
Comprar
</app-button>
<app-button
variant="secondary"
[disabled]="hasVariants() && selectedVariant() === null"
(click)="onAddToCart()"
>
Agregar al carrito
</app-button>
</div>
</div>
</article>

View File

@@ -40,7 +40,7 @@
}
&__description {
margin: 23px 0 ;
margin: 23px 0;
color: #6f6f6f;
font-size: 11px;
font-weight: 300;
@@ -60,6 +60,60 @@
padding-inline: 12px;
}
&__single-variant {
display: grid;
justify-items: center;
gap: 16px;
}
&__single-variant-row {
display: flex;
align-items: center;
width: 100%;
gap: 8px;
}
&__multiple-variants {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: center;
gap: 16px;
}
&__summary-column,
&__variant-selectors {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
min-width: 0;
}
&__variant-selectors {
align-items: stretch;
}
&__variant-select {
min-width: 0;
height: 40px;
border-color: #d8d8d8;
color: #6f6f6f;
font-size: 12px;
&:focus {
border-color: var(--tenant-primary, #009933);
box-shadow: 0 0 0 0.2rem color-mix(in srgb, transparent 75%, var(--tenant-primary, #009933));
}
}
&__single-variant-row &__variant-select {
flex: 1 1 auto;
}
&__single-variant-row app-quantity-selector {
flex: 0 0 auto;
}
&__price {
color: var(--tenant-primary, #009933);
font-size: 22px;
@@ -87,6 +141,12 @@
}
}
@media (max-width: 767.98px) {
.product-vertical-with-cart-card {
border-color: var(--color-primary, var(--tenant-primary, #009933));
}
}
@media (max-width: 420px) {
.product-vertical-with-cart-card {
padding: 42px 20px 21px;
@@ -98,6 +158,5 @@
&__summary {
padding-inline: 12px;
}
}
}

View File

@@ -83,7 +83,7 @@ describe('ProductVerticalWithCartCardComponent', () => {
) as HTMLButtonElement;
button.click();
expect(buySpy).toHaveBeenCalledWith({ quantity: 1 });
expect(buySpy).toHaveBeenCalledWith({ quantity: 1, variant: null });
});
it('emits addToCart with the current quantity', async () => {
@@ -98,7 +98,7 @@ describe('ProductVerticalWithCartCardComponent', () => {
) as HTMLButtonElement;
button.click();
expect(addToCartSpy).toHaveBeenCalledWith({ quantity: 3 });
expect(addToCartSpy).toHaveBeenCalledWith({ quantity: 3, variant: null });
});
it('uses the shared primary and secondary buttons', async () => {
@@ -115,4 +115,66 @@ describe('ProductVerticalWithCartCardComponent', () => {
element.querySelector('.product-vertical-with-cart-card__actions .btn-outline-primary'),
).not.toBeNull();
});
it('places a single variant selector in the same row as the quantity', async () => {
const fixture = await createComponent();
fixture.componentRef.setInput('variants', [
{ id: 10, values: { fecha: '10 de octubre' } },
{ id: 11, values: { fecha: '11 de octubre' } },
]);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const row = element.querySelector('.product-vertical-with-cart-card__single-variant-row');
expect(row?.querySelectorAll('.product-vertical-with-cart-card__variant-select')).toHaveLength(
1,
);
expect(row?.querySelector('app-quantity-selector')).not.toBeNull();
expect(element.querySelector('.product-vertical-with-cart-card__multiple-variants')).toBeNull();
});
it('uses separate summary and selector columns for multiple variant attributes', async () => {
const fixture = await createComponent();
fixture.componentRef.setInput('variants', [
{ id: 20, values: { fecha: '10 de octubre', turno: 'Mañana' } },
{ id: 21, values: { fecha: '10 de octubre', turno: 'Tarde' } },
]);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const layout = element.querySelector('.product-vertical-with-cart-card__multiple-variants');
expect(
layout?.querySelector('.product-vertical-with-cart-card__summary-column'),
).not.toBeNull();
expect(
layout?.querySelectorAll('.product-vertical-with-cart-card__variant-select'),
).toHaveLength(2);
});
it('emits the selected variant with the purchase action', async () => {
const fixture = await createComponent();
fixture.componentRef.setInput('variants', [
{ id: 30, values: { fecha: '10 de octubre' } },
{ id: 31, values: { fecha: '11 de octubre' } },
]);
fixture.detectChanges();
const buySpy = vi.fn();
fixture.componentInstance.buy.subscribe(buySpy);
const select = fixture.nativeElement.querySelector(
'.product-vertical-with-cart-card__variant-select',
) as HTMLSelectElement;
select.value = select.options[1].value;
select.dispatchEvent(new Event('change'));
fixture.detectChanges();
const button = fixture.nativeElement.querySelector(
'.product-vertical-with-cart-card__actions .btn-primary',
) as HTMLButtonElement;
button.click();
expect(buySpy).toHaveBeenCalledWith({ quantity: 1, variant: 31 });
});
});

View File

@@ -1,11 +1,33 @@
import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
input,
model,
output,
signal,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
export interface VerticalCartVariant {
id: number;
values: Record<string, string>;
}
interface VariantSelector {
key: string;
label: string;
options: string[];
}
@Component({
selector: 'app-product-vertical-with-cart-card',
imports: [ButtonComponent, QuantitySelectorComponent],
imports: [ButtonComponent, FormsModule, QuantitySelectorComponent],
templateUrl: './product-vertical-with-cart-card.component.html',
styleUrl: './product-vertical-with-cart-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -14,20 +36,80 @@ export class ProductVerticalWithCartCardComponent {
readonly title = input<string>('');
readonly description = input<string>('');
readonly price = input<number>(0);
readonly variants = input<VerticalCartVariant[]>([]);
readonly quantity = model<number>(1);
readonly selectedVariant = model<number | null>(null);
readonly buy = output<{ quantity: number }>();
readonly addToCart = output<{ quantity: number }>();
readonly buy = output<{ quantity: number; variant: number | null }>();
readonly addToCart = output<{ quantity: number; variant: number | null }>();
protected readonly selectedValues = signal<Record<string, string>>({});
protected readonly formattedPrice = computed(() => this.formatCurrency(this.price()));
protected readonly variantSelectors = computed<VariantSelector[]>(() => {
const variants = this.variants();
const keys = Array.from(new Set(variants.flatMap((variant) => Object.keys(variant.values))));
return keys.map((key) => ({
key,
label: this.formatVariantLabel(key),
options: Array.from(
new Set(
variants
.map((variant) => variant.values[key])
.filter((value): value is string => Boolean(value)),
),
),
}));
});
protected readonly hasVariants = computed(() => this.variantSelectors().length > 0);
protected readonly hasMultipleVariantSelectors = computed(
() => this.variantSelectors().length > 1,
);
constructor() {
effect(() => {
const variants = this.variants();
untracked(() => {
if (variants.length === 0) {
this.selectedValues.set({});
this.selectedVariant.set(null);
return;
}
const selected =
variants.find((variant) => variant.id === this.selectedVariant()) ?? variants[0];
this.selectedValues.set({ ...selected.values });
this.selectedVariant.set(selected.id);
});
});
}
protected onVariantValueChange(key: string, value: string): void {
const values = { ...this.selectedValues(), [key]: value };
const selectors = this.variantSelectors();
const matchingVariant = this.variants().find((variant) =>
selectors.every((selector) => variant.values[selector.key] === values[selector.key]),
);
this.selectedValues.set(values);
this.selectedVariant.set(matchingVariant?.id ?? null);
}
protected onAddToCart(): void {
this.addToCart.emit({ quantity: this.quantity() });
this.addToCart.emit({ quantity: this.quantity(), variant: this.selectedVariant() });
}
protected onBuy(): void {
this.buy.emit({ quantity: this.quantity() });
this.buy.emit({ quantity: this.quantity(), variant: this.selectedVariant() });
}
private formatVariantLabel(key: string): string {
const label = key.replace(/[_-]+/g, ' ');
return label.charAt(0).toUpperCase() + label.slice(1);
}
private formatCurrency(value: number): string {

View File

@@ -8,5 +8,5 @@
font-size: 15px;
font-weight: 700;
letter-spacing: 0.08em;
color: #000000;
color: var(--color-primary);
}