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="store-layout__header-main">
<div class="container-xl px-3 px-md-4 py-3 py-lg-4"> <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="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 <a
routerLink="/" routerLink="/"
class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none" class="store-layout__brand-slot d-inline-flex align-items-center gap-3 text-decoration-none"
@@ -24,7 +24,7 @@
</div> </div>
<div class="col-12 col-md order-4 order-md-2"> <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"> <div class="input-group store-layout__search" role="search">
<label class="visually-hidden" for="store-search-input">Buscar productos</label> <label class="visually-hidden" for="store-search-input">Buscar productos</label>
<input <input
@@ -58,7 +58,7 @@
</div> </div>
</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) { @if (ticketsMenu(); as menu) {
<app-button <app-button
variant="primary" variant="primary"

View File

@@ -12,6 +12,10 @@
min-width: 150px; min-width: 150px;
} }
.store-layout__brand-column {
max-width: 12rem;
}
.store-layout__brand-logo-shell { .store-layout__brand-logo-shell {
position: relative; position: relative;
width: min(100%, 12rem); width: min(100%, 12rem);
@@ -49,6 +53,15 @@
.store-layout__search-wrapper { .store-layout__search-wrapper {
width: min(100%, 260px); 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 { .store-layout__search {
@@ -145,13 +158,6 @@
max-width: 150px; max-width: 150px;
} }
.store-layout__tickets-btn-host {
::ng-deep .btn {
min-width: 0 !important;
padding-inline: 1rem;
}
}
.store-layout__mobile-separator { .store-layout__mobile-separator {
border-color: #DDDDDD; border-color: #DDDDDD;
opacity: 1; 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 { .store-layout__mobile-dropdown-menu {
top: 100%; top: 100%;
min-width: 220px; min-width: 220px;

View File

@@ -22,6 +22,7 @@
[title]="item.nombre" [title]="item.nombre"
[description]="item.descripcion ?? ''" [description]="item.descripcion ?? ''"
[price]="price(item)" [price]="price(item)"
[variants]="item.variants ?? []"
(buy)="emitColumnBuy(item, $event)" (buy)="emitColumnBuy(item, $event)"
(addToCart)="emitColumnCart(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 () => { it('renders pagination and emits the requested page', async () => {
const fixture = await render('row'); const fixture = await render('row');
const pageChangeSpy = vi.fn(); const pageChangeSpy = vi.fn();

View File

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

View File

@@ -8,14 +8,71 @@
</div> </div>
<div class="product-vertical-with-cart-card__purchase"> <div class="product-vertical-with-cart-card__purchase">
<div class="product-vertical-with-cart-card__summary"> @if (!hasVariants()) {
<span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span> <div class="product-vertical-with-cart-card__summary">
<app-quantity-selector [(quantity)]="quantity" /> <span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span>
</div> <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"> <div class="product-vertical-with-cart-card__actions">
<app-button variant="primary" (click)="onBuy()">Comprar</app-button> <app-button
<app-button variant="secondary" (click)="onAddToCart()"> Agregar al carrito </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>
</div> </div>
</article> </article>

View File

@@ -40,7 +40,7 @@
} }
&__description { &__description {
margin: 23px 0 ; margin: 23px 0;
color: #6f6f6f; color: #6f6f6f;
font-size: 11px; font-size: 11px;
font-weight: 300; font-weight: 300;
@@ -60,6 +60,60 @@
padding-inline: 12px; 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 { &__price {
color: var(--tenant-primary, #009933); color: var(--tenant-primary, #009933);
font-size: 22px; 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) { @media (max-width: 420px) {
.product-vertical-with-cart-card { .product-vertical-with-cart-card {
padding: 42px 20px 21px; padding: 42px 20px 21px;
@@ -98,6 +158,5 @@
&__summary { &__summary {
padding-inline: 12px; padding-inline: 12px;
} }
} }
} }

View File

@@ -83,7 +83,7 @@ describe('ProductVerticalWithCartCardComponent', () => {
) as HTMLButtonElement; ) as HTMLButtonElement;
button.click(); button.click();
expect(buySpy).toHaveBeenCalledWith({ quantity: 1 }); expect(buySpy).toHaveBeenCalledWith({ quantity: 1, variant: null });
}); });
it('emits addToCart with the current quantity', async () => { it('emits addToCart with the current quantity', async () => {
@@ -98,7 +98,7 @@ describe('ProductVerticalWithCartCardComponent', () => {
) as HTMLButtonElement; ) as HTMLButtonElement;
button.click(); button.click();
expect(addToCartSpy).toHaveBeenCalledWith({ quantity: 3 }); expect(addToCartSpy).toHaveBeenCalledWith({ quantity: 3, variant: null });
}); });
it('uses the shared primary and secondary buttons', async () => { 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'), element.querySelector('.product-vertical-with-cart-card__actions .btn-outline-primary'),
).not.toBeNull(); ).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 { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.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({ @Component({
selector: 'app-product-vertical-with-cart-card', selector: 'app-product-vertical-with-cart-card',
imports: [ButtonComponent, QuantitySelectorComponent], imports: [ButtonComponent, FormsModule, QuantitySelectorComponent],
templateUrl: './product-vertical-with-cart-card.component.html', templateUrl: './product-vertical-with-cart-card.component.html',
styleUrl: './product-vertical-with-cart-card.component.scss', styleUrl: './product-vertical-with-cart-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -14,20 +36,80 @@ export class ProductVerticalWithCartCardComponent {
readonly title = input<string>(''); readonly title = input<string>('');
readonly description = input<string>(''); readonly description = input<string>('');
readonly price = input<number>(0); readonly price = input<number>(0);
readonly variants = input<VerticalCartVariant[]>([]);
readonly quantity = model<number>(1); readonly quantity = model<number>(1);
readonly selectedVariant = model<number | null>(null);
readonly buy = output<{ quantity: number }>(); readonly buy = output<{ quantity: number; variant: number | null }>();
readonly addToCart = output<{ quantity: number }>(); 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 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 { protected onAddToCart(): void {
this.addToCart.emit({ quantity: this.quantity() }); this.addToCart.emit({ quantity: this.quantity(), variant: this.selectedVariant() });
} }
protected onBuy(): void { 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 { private formatCurrency(value: number): string {

View File

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