feat: enhance product variant handling and improve layout for better user interaction
This commit is contained in:
@@ -204,8 +204,9 @@ export class ReutilizablesTestPageComponent {
|
||||
'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' },
|
||||
{ value: '10-oct-manana', values: { fecha: '10 de Octubre', turno: 'Mañana' } },
|
||||
{ value: '10-oct-tarde', values: { fecha: '10 de Octubre', turno: 'Tarde' } },
|
||||
{ value: '11-oct-tarde', values: { fecha: '11 de Octubre', turno: 'Tarde' } },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div
|
||||
class="hero-media"
|
||||
aria-hidden="true"
|
||||
[ngStyle]="{'background-image': heroConfig?.background_image ? 'url(' + heroConfig.background_image + ')' : 'none'}"
|
||||
[ngStyle]="{'background-image': heroConfig?.background_image_id ? 'url(' + heroConfig.background_image_id + ')' : 'none'}"
|
||||
></div>
|
||||
|
||||
@if (heroConfig) {
|
||||
|
||||
@@ -108,9 +108,9 @@ export class ProductListComponent {
|
||||
protected variantsFor(item: ProductListItem): RowVariant[] {
|
||||
return (item.variants ?? []).map((variant) => ({
|
||||
value: variant.id,
|
||||
label: Object.values(variant.values).join(' / ') || `Variante ${variant.id}`,
|
||||
descripcion: variant.descripcion,
|
||||
precio: variant.precio,
|
||||
values: variant.values,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -25,15 +25,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Row: Select, Quantity, and Agregar al carrito button -->
|
||||
<!-- Bottom Row: Attribute selects, 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 [ngValue]="variant.value">{{ variant.label }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
<div class="product-row-card__selectors">
|
||||
@for (selector of variantSelectors(); track selector.key) {
|
||||
<select
|
||||
class="form-select product-row-card__select"
|
||||
[attr.aria-label]="selector.label"
|
||||
[ngModel]="selectedValues()[selector.key]"
|
||||
(ngModelChange)="onVariantValueChange(selector.key, $event)"
|
||||
>
|
||||
@for (option of selector.options; track option.key) {
|
||||
<option [ngValue]="option.value">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
|
||||
<app-quantity-selector [(quantity)]="quantity"></app-quantity-selector>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
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. */
|
||||
}
|
||||
@@ -31,9 +31,16 @@
|
||||
color: var(--tenant-primary, #009933) !important; /* Green matching screenshot */
|
||||
}
|
||||
|
||||
&__selectors {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__select {
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
min-width: 120px;
|
||||
font-size: 14px;
|
||||
height: 38px;
|
||||
color: #666;
|
||||
|
||||
@@ -4,18 +4,35 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
output,
|
||||
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 Variant {
|
||||
label: string;
|
||||
value: any;
|
||||
label?: string;
|
||||
value: unknown;
|
||||
descripcion?: string | null;
|
||||
precio?: string | number;
|
||||
values: Record<string, string | string[]>;
|
||||
}
|
||||
|
||||
type VariantAttributeValue = string | string[];
|
||||
|
||||
interface VariantSelectorOption {
|
||||
key: string;
|
||||
label: string;
|
||||
value: VariantAttributeValue;
|
||||
}
|
||||
|
||||
interface VariantSelector {
|
||||
key: string;
|
||||
label: string;
|
||||
options: VariantSelectorOption[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -35,23 +52,54 @@ export class ProductRowCardComponent {
|
||||
|
||||
// Internal state models
|
||||
readonly quantity = model<number>(1);
|
||||
readonly selectedVariant = model<any>(null);
|
||||
readonly selectedVariant = model<unknown>(null);
|
||||
|
||||
// Interactive events
|
||||
readonly buy = output<{ quantity: number; variant: any }>();
|
||||
readonly addToCart = output<{ quantity: number; variant: any }>();
|
||||
readonly buy = output<{ quantity: number; variant: unknown }>();
|
||||
readonly addToCart = output<{ quantity: number; variant: unknown }>();
|
||||
|
||||
protected readonly selectedValues = signal<Record<string, VariantAttributeValue>>({});
|
||||
protected readonly attributeKeys = computed(() =>
|
||||
Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))),
|
||||
);
|
||||
protected readonly variantSelectors = computed<VariantSelector[]>(() => {
|
||||
const variants = this.variants();
|
||||
const keys = this.attributeKeys();
|
||||
const selectedValues = this.selectedValues();
|
||||
|
||||
return keys.map((key, index) => {
|
||||
const previousKeys = keys.slice(0, index);
|
||||
const compatibleVariants = variants.filter((variant) =>
|
||||
previousKeys.every((previousKey) =>
|
||||
this.sameValue(variant.values[previousKey], selectedValues[previousKey]),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
key,
|
||||
label: this.formatVariantLabel(key),
|
||||
options: this.optionsFor(compatibleVariants, key),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const variants = this.variants();
|
||||
const selectedVariant = this.selectedVariant();
|
||||
|
||||
if (
|
||||
variants.length > 0 &&
|
||||
!variants.some((variant) => Object.is(variant.value, selectedVariant))
|
||||
) {
|
||||
this.selectedVariant.set(variants[0].value);
|
||||
}
|
||||
untracked(() => {
|
||||
if (variants.length === 0) {
|
||||
this.selectedValues.set({});
|
||||
this.selectedVariant.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected =
|
||||
variants.find((variant) => Object.is(variant.value, selectedVariant)) ?? variants[0];
|
||||
this.selectedValues.set({ ...selected.values });
|
||||
this.selectedVariant.set(selected.value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,6 +117,42 @@ export class ProductRowCardComponent {
|
||||
|
||||
readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice()));
|
||||
|
||||
protected onVariantValueChange(key: string, value: VariantAttributeValue): void {
|
||||
const variants = this.variants();
|
||||
const keys = this.attributeKeys();
|
||||
const changedIndex = keys.indexOf(key);
|
||||
const values = { ...this.selectedValues(), [key]: value };
|
||||
|
||||
for (let index = changedIndex + 1; index < keys.length; index++) {
|
||||
const currentKey = keys[index];
|
||||
const previousKeys = keys.slice(0, index);
|
||||
const compatibleVariants = variants.filter((variant) =>
|
||||
previousKeys.every((previousKey) =>
|
||||
this.sameValue(variant.values[previousKey], values[previousKey]),
|
||||
),
|
||||
);
|
||||
const options = this.optionsFor(compatibleVariants, currentKey);
|
||||
|
||||
if (!options.some((option) => this.sameValue(option.value, values[currentKey]))) {
|
||||
const firstOption = options[0];
|
||||
if (firstOption) {
|
||||
values[currentKey] = firstOption.value;
|
||||
} else {
|
||||
delete values[currentKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const matchingVariant = variants.find((variant) =>
|
||||
keys.every((attributeKey) =>
|
||||
this.sameValue(variant.values[attributeKey], values[attributeKey]),
|
||||
),
|
||||
);
|
||||
|
||||
this.selectedValues.set(values);
|
||||
this.selectedVariant.set(matchingVariant?.value ?? null);
|
||||
}
|
||||
|
||||
protected onAddToCart(): void {
|
||||
this.addToCart.emit({
|
||||
quantity: this.quantity(),
|
||||
@@ -83,6 +167,46 @@ export class ProductRowCardComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private optionsFor(variants: Variant[], key: string): VariantSelectorOption[] {
|
||||
const options = new Map<string, VariantSelectorOption>();
|
||||
|
||||
for (const variant of variants) {
|
||||
const value = variant.values[key];
|
||||
if (value === undefined || value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const optionKey = this.valueKey(value);
|
||||
if (!options.has(optionKey)) {
|
||||
options.set(optionKey, {
|
||||
key: optionKey,
|
||||
label: Array.isArray(value) ? value.join(', ') : value,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(options.values());
|
||||
}
|
||||
|
||||
private sameValue(
|
||||
left: VariantAttributeValue | undefined,
|
||||
right: VariantAttributeValue | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
left !== undefined && right !== undefined && this.valueKey(left) === this.valueKey(right)
|
||||
);
|
||||
}
|
||||
|
||||
private valueKey(value: VariantAttributeValue): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
private formatVariantLabel(key: string): string {
|
||||
const label = key.replace(/[_-]+/g, ' ');
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to format currency numbers to Argentine Pesos style "$ XX.XXX".
|
||||
*/
|
||||
|
||||
@@ -13,30 +13,9 @@
|
||||
<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">
|
||||
<div class="product-vertical-with-cart-card__variants">
|
||||
<div class="product-vertical-with-cart-card__summary">
|
||||
<span class="product-vertical-with-cart-card__price">{{ formattedPrice() }}</span>
|
||||
<app-quantity-selector [(quantity)]="quantity" />
|
||||
</div>
|
||||
|
||||
@@ -60,41 +60,20 @@
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
&__single-variant {
|
||||
&__variants {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__single-variant-row {
|
||||
&__variant-selectors {
|
||||
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 {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
border-color: #d8d8d8;
|
||||
@@ -107,14 +86,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__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;
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('ProductVerticalWithCartCardComponent', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('places a single variant selector in the same row as the quantity', async () => {
|
||||
it('places price and quantity together above a single variant selector', async () => {
|
||||
const fixture = await createComponent();
|
||||
fixture.componentRef.setInput('variants', [
|
||||
{ id: 10, values: { fecha: '10 de octubre' } },
|
||||
@@ -125,16 +125,18 @@ describe('ProductVerticalWithCartCardComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const row = element.querySelector('.product-vertical-with-cart-card__single-variant-row');
|
||||
const summary = element.querySelector('.product-vertical-with-cart-card__summary');
|
||||
const selectors = element.querySelector('.product-vertical-with-cart-card__variant-selectors');
|
||||
|
||||
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();
|
||||
expect(summary?.querySelector('.product-vertical-with-cart-card__price')).not.toBeNull();
|
||||
expect(summary?.querySelector('app-quantity-selector')).not.toBeNull();
|
||||
expect(
|
||||
selectors?.querySelectorAll('.product-vertical-with-cart-card__variant-select'),
|
||||
).toHaveLength(1);
|
||||
expect(selectors?.querySelector('app-quantity-selector')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses separate summary and selector columns for multiple variant attributes', async () => {
|
||||
it('places all variant selectors together below price and quantity', async () => {
|
||||
const fixture = await createComponent();
|
||||
fixture.componentRef.setInput('variants', [
|
||||
{ id: 20, values: { fecha: '10 de octubre', turno: 'Mañana' } },
|
||||
@@ -143,13 +145,13 @@ describe('ProductVerticalWithCartCardComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const layout = element.querySelector('.product-vertical-with-cart-card__multiple-variants');
|
||||
const layout = element.querySelector('.product-vertical-with-cart-card__variants');
|
||||
|
||||
expect(layout?.querySelector('.product-vertical-with-cart-card__summary')).not.toBeNull();
|
||||
expect(
|
||||
layout?.querySelector('.product-vertical-with-cart-card__summary-column'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
layout?.querySelectorAll('.product-vertical-with-cart-card__variant-select'),
|
||||
layout?.querySelectorAll(
|
||||
'.product-vertical-with-cart-card__variant-selectors .product-vertical-with-cart-card__variant-select',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -78,9 +78,6 @@ export class ProductVerticalWithCartCardComponent {
|
||||
});
|
||||
|
||||
protected readonly hasVariants = computed(() => this.variantSelectors().length > 0);
|
||||
protected readonly hasMultipleVariantSelectors = computed(
|
||||
() => this.variantSelectors().length > 1,
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
|
||||
Reference in New Issue
Block a user