feat: enhance product variant handling and improve layout for better user interaction

This commit is contained in:
2026-08-10 09:58:51 -03:00
parent 1977df6765
commit 7c2a098696
10 changed files with 185 additions and 97 deletions

View File

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

View File

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

View File

@@ -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".
*/