feat: implement variant selection in cart and product components for enhanced user experience

This commit is contained in:
2026-08-10 12:08:41 -03:00
parent dd828d9a4e
commit d6bab24e1d
17 changed files with 478 additions and 234 deletions

View File

@@ -69,6 +69,21 @@ export class StoreLayoutComponent implements OnInit {
});
}
const variants = (item.product?.variants ?? []).map((variant) => ({
value: variant.id,
values: variant.values,
}));
const selectedVariant = item.product?.variants?.find(
(variant) => variant.id === item.variant_id,
);
if (selectedVariant) {
attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({
label: this.formatAttributeLabel(label),
value: Array.isArray(value) ? value.join(', ') : value,
}));
}
return {
cartItemId: item.id,
imageUrl: item.product?.imagen ?? null,
@@ -78,9 +93,16 @@ export class StoreLayoutComponent implements OnInit {
discountPercentage: null,
attributes,
quantity: item.cantidad,
variantId: item.variant_id,
variants,
};
}
private formatAttributeLabel(value: string): string {
const label = value.replace(/[_-]+/g, ' ');
return label.charAt(0).toUpperCase() + label.slice(1);
}
protected readonly currentYear = new Date().getFullYear();
protected readonly tenant = this.tenantService.tenant;
protected readonly user = this.authService.user;

View File

@@ -1,6 +1,14 @@
export interface CartItemProduct {
nombre: string;
imagen: string | null;
variants?: CartItemVariant[];
}
export interface CartItemVariant {
id: number;
precio: string;
stock_tecnico: number | null;
values: Record<string, string | string[]>;
}
export interface CartItem {

View File

@@ -50,9 +50,7 @@ export class CartService extends BaseApiService {
): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true);
return this.http
.post<
ApiResponse<Cart>
>(
.post<ApiResponse<Cart>>(
`${this.tenantApiUrl}/cart/items`,
{ catalog_item_id: catalogItemId, variant_id: variantId, cantidad },
{
@@ -71,21 +69,27 @@ export class CartService extends BaseApiService {
);
}
updateItemQuantity(
updateItemQuantity(cartItemId: number, cantidad: number): Observable<ApiResponse<Cart>> {
return this.updateItem(cartItemId, { cantidad });
}
updateItemVariant(
cartItemId: number,
cantidad: number,
variantId: number,
): Observable<ApiResponse<Cart>> {
return this.updateItem(cartItemId, { cantidad, variant_id: variantId });
}
private updateItem(
cartItemId: number,
payload: { cantidad: number; variant_id?: number },
): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true);
return this.http
.patch<
ApiResponse<Cart>
>(
`${this.tenantApiUrl}/cart/items/${cartItemId}`,
{ cantidad },
{
withCredentials: true,
},
)
.patch<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, payload, {
withCredentials: true,
})
.pipe(
tap((response) => {
this.cartState.set(response.data);
@@ -101,9 +105,7 @@ export class CartService extends BaseApiService {
removeItem(cartItemId: number): Observable<ApiResponse<Cart>> {
this.isUpdatingState.set(true);
return this.http
.delete<
ApiResponse<Cart>
>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, {
.delete<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, {
withCredentials: true,
})
.pipe(

View File

@@ -1,11 +1,10 @@
<article
class="w-100 p-3 rounded-0 cart-item"
[class.cart-item-with-media]="imageUrl()"
>
<article class="w-100 p-3 rounded-0 cart-item" [class.cart-item-with-media]="imageUrl()">
@if (imageUrl()) {
<div class="position-relative overflow-hidden cart-item-media">
@if (discountPercentage() && discountPercentage()! > 0) {
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge">-{{ discountPercentage() }}%</span>
<span class="position-absolute top-0 start-0 z-1 rounded-0 cart-item-discount-badge"
>-{{ discountPercentage() }}%</span
>
}
<img class="w-100 h-100 object-fit-cover d-block" [src]="imageUrl()" [alt]="product()" />
@@ -18,21 +17,33 @@
<div class="text-nowrap cart-item-prices">
@if (formattedOriginalPrice(); as originalPrice) {
<span class="text-decoration-line-through mb-1 fw-light cart-item-original-price">{{ originalPrice }}</span>
<span class="text-decoration-line-through mb-1 fw-light cart-item-original-price">{{
originalPrice
}}</span>
}
<span class="fw-bold cart-item-discounted-price">{{ formattedDiscountedPrice() }}</span>
</div>
</div>
<div class="d-grid cart-item-attributes">
@for (attribute of attributes(); track attribute.label + attribute.value) {
<div class="cart-item-attribute">
<span class="fw-normal cart-item-attribute-label">{{ attribute.label }}: </span>
<span class="fw-bold">{{ attribute.value }}</span>
</div>
}
</div>
@if (hasVariantSelectors() && !quantityDisabled()) {
<app-variant-selector
class="cart-item-variant-selector"
[variants]="variants()"
[selectedVariant]="selectedVariant()"
[compact]="true"
(selectedVariantChange)="onVariantChange($event)"
/>
} @else {
<div class="d-grid cart-item-attributes">
@for (attribute of attributes(); track attribute.label + attribute.value) {
<div class="cart-item-attribute">
<span class="fw-normal cart-item-attribute-label">{{ attribute.label }}: </span>
<span class="fw-bold">{{ attribute.value }}</span>
</div>
}
</div>
}
@if (!readonly()) {
<div class="d-flex justify-content-end align-items-center mt-auto cart-item-actions">
@@ -45,15 +56,9 @@
(decrease)="onDecrease()"
/>
@if (!quantityDisabled() && showRemove()) {
<app-icon-button
variant="trash"
class="cart-item-remove-btn"
(click)="onRemove()"
/>
<app-icon-button variant="trash" class="cart-item-remove-btn" (click)="onRemove()" />
}
</div>
}
</div>
</article>

View File

@@ -11,7 +11,7 @@
:host(:first-child) .cart-item::before,
:host .cart-item::after {
content: "";
content: '';
position: absolute;
right: var(--item-divider-inset);
left: var(--item-divider-inset);
@@ -119,6 +119,11 @@
gap: 0.125rem;
}
.cart-item-variant-selector {
display: flex;
justify-content: flex-end;
}
.cart-item-remove-btn {
margin-left: 0.35rem;
}

View File

@@ -1,6 +1,10 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
import { IconButtonComponent } from '../icon-button/icon-button.component';
import {
VariantSelectorComponent,
VariantSelectorVariant,
} from '../variant-selector/variant-selector.component';
export interface CartItemAttribute {
label: string;
@@ -10,10 +14,10 @@ export interface CartItemAttribute {
@Component({
selector: 'app-cart-item',
standalone: true,
imports: [QuantitySelectorComponent, IconButtonComponent],
imports: [QuantitySelectorComponent, IconButtonComponent, VariantSelectorComponent],
templateUrl: './cart-item.component.html',
styleUrl: './cart-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CartItemComponent {
readonly imageUrl = input<string | null>(null);
@@ -22,6 +26,8 @@ export class CartItemComponent {
readonly discountedPrice = input<number>(0);
readonly discountPercentage = input<number | null>(null);
readonly attributes = input<CartItemAttribute[]>([]);
readonly variants = input<VariantSelectorVariant[]>([]);
readonly selectedVariant = input<unknown>(null);
readonly quantity = input<number>(1);
readonly readonly = input<boolean>(false);
readonly quantityDisabled = input<boolean>(false);
@@ -31,6 +37,11 @@ export class CartItemComponent {
readonly remove = output<void>();
readonly increase = output<void>();
readonly decrease = output<void>();
readonly variantChange = output<number>();
protected readonly hasVariantSelectors = computed(() =>
this.variants().some((variant) => Object.keys(variant.values).length > 0),
);
protected onQuantityChange(newQuantity: number): void {
if (this.quantityDisabled()) return;
@@ -49,12 +60,20 @@ export class CartItemComponent {
this.decrease.emit();
}
protected onVariantChange(variant: unknown): void {
if (!this.quantityDisabled() && typeof variant === 'number') {
this.variantChange.emit(variant);
}
}
protected readonly formattedOriginalPrice = computed(() => {
const price = this.originalPrice();
return price === null ? null : this.formatCurrency(price);
});
protected readonly formattedDiscountedPrice = computed(() => this.formatCurrency(this.discountedPrice()));
protected readonly formattedDiscountedPrice = computed(() =>
this.formatCurrency(this.discountedPrice()),
);
private formatCurrency(value: number): string {
const rounded = Math.round(value);

View File

@@ -44,11 +44,14 @@
[discountedPrice]="item.discountedPrice"
[discountPercentage]="item.discountPercentage"
[attributes]="item.attributes"
[variants]="item.variants ?? []"
[selectedVariant]="getItemVariant(item)"
[quantity]="getItemQuantity(item)"
[readonly]="readonly()"
[quantityDisabled]="editingDisabled() || (allowEditing() && !editing())"
[showRemove]="allowRemove()"
(quantityChange)="onItemQuantityChange(idx, $event)"
(variantChange)="onItemVariantChange(idx, $event)"
(remove)="onItemRemove(idx)"
/>
} @empty {

View File

@@ -407,4 +407,57 @@ describe('CartComponent', () => {
fixture.debugElement.query(By.css('app-cart-item')).componentInstance.quantityDisabled(),
).toBe(false);
});
it('persists a variant selected from a cart row', async () => {
const updateItemVariant = vi.fn().mockReturnValue(
of({
message: 'Variante actualizada.',
data: {},
}),
);
await TestBed.configureTestingModule({
imports: [CartComponent],
providers: [
{
provide: CartService,
useValue: {
cart: signal(null).asReadonly(),
updateItemQuantity: vi.fn(),
updateItemVariant,
removeItem: vi.fn(),
},
},
{ provide: ModalService, useValue: {} },
{
provide: ToastService,
useValue: { success: vi.fn(), info: vi.fn(), danger: vi.fn() },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(CartComponent);
fixture.componentRef.setInput('items', [
{
cartItemId: 10,
imageUrl: null,
product: 'Comida',
originalPrice: null,
discountedPrice: 1000,
discountPercentage: null,
attributes: [{ label: 'Servicio', value: 'Almuerzo' }],
quantity: 2,
variantId: 20,
variants: [
{ value: 20, values: { servicio: 'Almuerzo' } },
{ value: 21, values: { servicio: 'Cena' } },
],
},
]);
fixture.detectChanges();
fixture.debugElement.query(By.css('app-cart-item')).triggerEventHandler('variantChange', 21);
expect(updateItemVariant).toHaveBeenCalledWith(10, 2, 21);
});
});

View File

@@ -16,6 +16,7 @@ import { CartItemAttribute, CartItemComponent } from '../cart-item/cart-item.com
import { ModalService } from '../../../core/services/modal.service';
import { CartService } from '../../../core/services/cart/cart.service';
import { ToastService } from '../../../core/services/toast.service';
import { VariantSelectorVariant } from '../variant-selector/variant-selector.component';
export interface CartItemMock {
cartItemId?: number;
@@ -26,6 +27,8 @@ export interface CartItemMock {
discountPercentage: number | null;
attributes: CartItemAttribute[];
quantity: number;
variantId?: number | null;
variants?: VariantSelectorVariant[];
}
@Component({
@@ -64,6 +67,7 @@ export class CartComponent {
}>();
protected readonly quantityOverrides = signal<Record<number, number>>({});
protected readonly variantOverrides = signal<Record<number, number>>({});
constructor() {
this.quantityUpdates$
.pipe(
@@ -104,6 +108,13 @@ export class CartComponent {
return item.quantity;
}
protected getItemVariant(item: CartItemMock): number | null {
if (item.cartItemId !== undefined && this.variantOverrides()[item.cartItemId] !== undefined) {
return this.variantOverrides()[item.cartItemId];
}
return item.variantId ?? null;
}
private clearOverride(cartItemId: number): void {
this.quantityOverrides.update((overrides) => {
const copy = { ...overrides };
@@ -153,6 +164,36 @@ export class CartComponent {
}
}
protected onItemVariantChange(index: number, variantId: number): void {
const item = this.items()[index];
const cartItemId = item?.cartItemId;
if (!item || !cartItemId || variantId === this.getItemVariant(item)) return;
this.variantOverrides.update((overrides) => ({ ...overrides, [cartItemId]: variantId }));
this.cartService
.updateItemVariant(cartItemId, this.getItemQuantity(item), variantId)
.subscribe({
next: (response) => {
this.clearVariantOverride(cartItemId);
this.toastService.success(response.message || 'Variante actualizada.');
},
error: (error: HttpErrorResponse) => {
console.error('Error updating cart item variant', error);
this.clearVariantOverride(cartItemId);
this.toastService.danger(error.error?.message || 'No se pudo actualizar la variante.');
},
});
}
private clearVariantOverride(cartItemId: number): void {
this.variantOverrides.update((overrides) => {
const copy = { ...overrides };
delete copy[cartItemId];
return copy;
});
}
protected onItemRemove(index: number): void {
const target = this.resolveRemoveTarget(index);

View File

@@ -27,20 +27,11 @@
<!-- Bottom Row: Attribute selects, Quantity, and Agregar al carrito button -->
<div class="d-flex align-items-center justify-content-end gap-2">
<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-variant-selector
class="product-row-card__selectors"
[variants]="variants()"
[(selectedVariant)]="selectedVariant"
/>
<app-quantity-selector [(quantity)]="quantity"></app-quantity-selector>

View File

@@ -31,28 +31,6 @@
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: 120px;
font-size: 14px;
height: 38px;
color: #666;
border-color: #ccc;
cursor: pointer;
&:focus {
border-color: var(--tenant-primary);
box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25);
}
}
&__btn-wrapper {
min-width: 160px; /* To make both buttons equal width as in screenshot */

View File

@@ -1,44 +1,21 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
input,
model,
output,
signal,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
import { ButtonComponent } from '../button/button.component';
import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component';
import {
VariantSelectorComponent,
VariantSelectorVariant,
} from '../variant-selector/variant-selector.component';
export interface Variant {
export interface Variant extends VariantSelectorVariant {
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({
selector: 'app-product-row-card',
standalone: true,
imports: [ButtonComponent, QuantitySelectorComponent, FormsModule],
imports: [ButtonComponent, QuantitySelectorComponent, VariantSelectorComponent],
templateUrl: './product-row-card.component.html',
styleUrl: './product-row-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -58,51 +35,6 @@ export class ProductRowCardComponent {
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();
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);
});
});
}
protected readonly selectedVariantData = computed(() =>
this.variants().find((variant) => Object.is(variant.value, this.selectedVariant())),
);
@@ -117,42 +49,6 @@ 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(),
@@ -167,46 +63,6 @@ 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".
*/

View File

@@ -7,7 +7,7 @@
width: 78px;
height: 40px;
min-height: 40px;
background: inherit;
background-color: #ffffff;
box-sizing: border-box;
&__button {
@@ -15,8 +15,10 @@
height: 100%;
font-size: 20px;
color: #6f6f6f;
background: inherit;
transition: background-color 0.2s ease, color 0.2s ease;
background-color: #ffffff;
transition:
background-color 0.2s ease,
color 0.2s ease;
cursor: pointer;
&:not(:disabled):hover {
@@ -25,7 +27,7 @@
}
&:disabled {
color: #A0A0A0;
color: #a0a0a0;
opacity: 1;
cursor: not-allowed;
}
@@ -41,12 +43,12 @@
height: 21px;
min-height: 21px;
border: 1px solid #cfcfcf;
background: inherit;
background-color: #ffffff;
.quantity-selector__button {
width: 15px;
font-size: 10px;
background: inherit;
background-color: #ffffff;
color: #666666;
&:not(:disabled):hover {

View File

@@ -0,0 +1,17 @@
@if (selectors().length > 0) {
<div class="variant-selector" [class.variant-selector--compact]="compact()">
@for (selector of selectors(); track selector.key) {
<select
class="form-select variant-selector__select"
[attr.aria-label]="selector.label"
[disabled]="disabled()"
[ngModel]="selectedValues()[selector.key]"
(ngModelChange)="onValueChange(selector.key, $event)"
>
@for (option of selector.options; track option.key) {
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>
}
</div>
}

View File

@@ -0,0 +1,40 @@
:host {
display: block;
min-width: 0;
}
.variant-selector {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.variant-selector__select {
width: auto;
min-width: 120px;
height: 38px;
color: #666666;
border-color: #cccccc;
font-size: 14px;
cursor: pointer;
&:focus {
border-color: var(--tenant-primary);
box-shadow: 0 0 0 0.25rem rgba(0, 153, 51, 0.25);
}
}
.variant-selector--compact {
justify-content: flex-end;
gap: 4px;
.variant-selector__select {
min-width: 0;
max-width: 150px;
height: 28px;
padding: 0.2rem 1.75rem 0.2rem 0.45rem;
font-size: 11px;
}
}

View File

@@ -0,0 +1,41 @@
import '@angular/compiler';
import { TestBed, getTestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { VariantSelectorComponent } from './variant-selector.component';
describe('VariantSelectorComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => TestBed.resetTestingModule());
it('updates following selections to the first compatible variant', async () => {
await TestBed.configureTestingModule({
imports: [VariantSelectorComponent],
}).compileComponents();
const fixture = TestBed.createComponent(VariantSelectorComponent);
fixture.componentRef.setInput('variants', [
{ value: 1, values: { alojamiento: 'Carpa', servicio: 'Almuerzo' } },
{ value: 2, values: { alojamiento: 'Carpa', servicio: 'Cena' } },
{ value: 3, values: { alojamiento: 'Hotel', servicio: 'Cena' } },
]);
fixture.componentRef.setInput('selectedVariant', 1);
fixture.detectChanges();
(fixture.componentInstance as any).onValueChange('alojamiento', 'Hotel');
fixture.detectChanges();
expect(fixture.componentInstance.selectedVariant()).toBe(3);
expect((fixture.componentInstance as any).selectedValues()).toEqual({
alojamiento: 'Hotel',
servicio: 'Cena',
});
});
});

View File

@@ -0,0 +1,161 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
input,
model,
signal,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
export type VariantAttributeValue = string | string[];
export interface VariantSelectorVariant {
value: unknown;
values: Record<string, VariantAttributeValue>;
}
interface VariantSelectorOption {
key: string;
label: string;
value: VariantAttributeValue;
}
interface VariantSelectorGroup {
key: string;
label: string;
options: VariantSelectorOption[];
}
@Component({
selector: 'app-variant-selector',
standalone: true,
imports: [FormsModule],
templateUrl: './variant-selector.component.html',
styleUrl: './variant-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VariantSelectorComponent {
readonly variants = input<VariantSelectorVariant[]>([]);
readonly selectedVariant = model<unknown>(null);
readonly disabled = input(false);
readonly compact = input(false);
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 selectors = computed<VariantSelectorGroup[]>(() => {
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();
untracked(() => {
if (variants.length === 0) {
this.selectedValues.set({});
if (selectedVariant !== null) this.selectedVariant.set(null);
return;
}
const selected =
variants.find((variant) => Object.is(variant.value, selectedVariant)) ?? variants[0];
this.selectedValues.set({ ...selected.values });
if (!Object.is(selected.value, selectedVariant)) this.selectedVariant.set(selected.value);
});
});
}
protected onValueChange(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);
}
private optionsFor(variants: VariantSelectorVariant[], 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);
}
}