feat: enhance product vertical card with variant selection and improve store header responsiveness
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
[title]="item.nombre"
|
||||
[description]="item.descripcion ?? ''"
|
||||
[price]="price(item)"
|
||||
[variants]="item.variants ?? []"
|
||||
(buy)="emitColumnBuy(item, $event)"
|
||||
(addToCart)="emitColumnCart(item, $event)"
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: #000000;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user