From d6bab24e1d5cfa30d66cea968736ec1221ba07c0 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 10 Aug 2026 12:08:41 -0300 Subject: [PATCH] feat: implement variant selection in cart and product components for enhanced user experience --- .../store-layout/store-layout.component.ts | 22 +++ src/app/core/services/cart/cart.interface.ts | 8 + src/app/core/services/cart/cart.service.ts | 34 ++-- .../cart-item/cart-item.component.html | 47 ++--- .../cart-item/cart-item.component.scss | 7 +- .../cart-item/cart-item.component.ts | 25 ++- .../components/cart/cart.component.html | 3 + .../components/cart/cart.component.spec.ts | 53 ++++++ .../shared/components/cart/cart.component.ts | 41 +++++ .../product-row-card.component.html | 19 +-- .../product-row-card.component.scss | 22 --- .../product-row-card.component.ts | 158 +---------------- .../quantity-selector.component.scss | 14 +- .../variant-selector.component.html | 17 ++ .../variant-selector.component.scss | 40 +++++ .../variant-selector.component.spec.ts | 41 +++++ .../variant-selector.component.ts | 161 ++++++++++++++++++ 17 files changed, 478 insertions(+), 234 deletions(-) create mode 100644 src/app/shared/components/variant-selector/variant-selector.component.html create mode 100644 src/app/shared/components/variant-selector/variant-selector.component.scss create mode 100644 src/app/shared/components/variant-selector/variant-selector.component.spec.ts create mode 100644 src/app/shared/components/variant-selector/variant-selector.component.ts diff --git a/src/app/core/layout/store-layout/store-layout.component.ts b/src/app/core/layout/store-layout/store-layout.component.ts index 8336100..98810f6 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -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; diff --git a/src/app/core/services/cart/cart.interface.ts b/src/app/core/services/cart/cart.interface.ts index c774367..3145a6c 100644 --- a/src/app/core/services/cart/cart.interface.ts +++ b/src/app/core/services/cart/cart.interface.ts @@ -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; } export interface CartItem { diff --git a/src/app/core/services/cart/cart.service.ts b/src/app/core/services/cart/cart.service.ts index 4a74bf2..750105f 100644 --- a/src/app/core/services/cart/cart.service.ts +++ b/src/app/core/services/cart/cart.service.ts @@ -50,9 +50,7 @@ export class CartService extends BaseApiService { ): Observable> { this.isUpdatingState.set(true); return this.http - .post< - ApiResponse - >( + .post>( `${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> { + return this.updateItem(cartItemId, { cantidad }); + } + + updateItemVariant( cartItemId: number, cantidad: number, + variantId: number, + ): Observable> { + return this.updateItem(cartItemId, { cantidad, variant_id: variantId }); + } + + private updateItem( + cartItemId: number, + payload: { cantidad: number; variant_id?: number }, ): Observable> { this.isUpdatingState.set(true); return this.http - .patch< - ApiResponse - >( - `${this.tenantApiUrl}/cart/items/${cartItemId}`, - { cantidad }, - { - withCredentials: true, - }, - ) + .patch>(`${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> { this.isUpdatingState.set(true); return this.http - .delete< - ApiResponse - >(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { + .delete>(`${this.tenantApiUrl}/cart/items/${cartItemId}`, { withCredentials: true, }) .pipe( diff --git a/src/app/shared/components/cart-item/cart-item.component.html b/src/app/shared/components/cart-item/cart-item.component.html index d425ffe..e934db3 100644 --- a/src/app/shared/components/cart-item/cart-item.component.html +++ b/src/app/shared/components/cart-item/cart-item.component.html @@ -1,11 +1,10 @@ -
+
@if (imageUrl()) {
@if (discountPercentage() && discountPercentage()! > 0) { - -{{ discountPercentage() }}% + -{{ discountPercentage() }}% } @@ -18,21 +17,33 @@
@if (formattedOriginalPrice(); as originalPrice) { - {{ originalPrice }} + {{ + originalPrice + }} } {{ formattedDiscountedPrice() }}
-
- @for (attribute of attributes(); track attribute.label + attribute.value) { -
- {{ attribute.label }}: - {{ attribute.value }} -
- } -
+ @if (hasVariantSelectors() && !quantityDisabled()) { + + } @else { +
+ @for (attribute of attributes(); track attribute.label + attribute.value) { +
+ {{ attribute.label }}: + {{ attribute.value }} +
+ } +
+ } @if (!readonly()) {
@@ -45,15 +56,9 @@ (decrease)="onDecrease()" /> @if (!quantityDisabled() && showRemove()) { - + }
}
- - diff --git a/src/app/shared/components/cart-item/cart-item.component.scss b/src/app/shared/components/cart-item/cart-item.component.scss index 2a73769..0d0d6c3 100644 --- a/src/app/shared/components/cart-item/cart-item.component.scss +++ b/src/app/shared/components/cart-item/cart-item.component.scss @@ -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; } diff --git a/src/app/shared/components/cart-item/cart-item.component.ts b/src/app/shared/components/cart-item/cart-item.component.ts index 7c752fe..72b2449 100644 --- a/src/app/shared/components/cart-item/cart-item.component.ts +++ b/src/app/shared/components/cart-item/cart-item.component.ts @@ -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(null); @@ -22,6 +26,8 @@ export class CartItemComponent { readonly discountedPrice = input(0); readonly discountPercentage = input(null); readonly attributes = input([]); + readonly variants = input([]); + readonly selectedVariant = input(null); readonly quantity = input(1); readonly readonly = input(false); readonly quantityDisabled = input(false); @@ -31,6 +37,11 @@ export class CartItemComponent { readonly remove = output(); readonly increase = output(); readonly decrease = output(); + readonly variantChange = output(); + + 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); diff --git a/src/app/shared/components/cart/cart.component.html b/src/app/shared/components/cart/cart.component.html index a04b6d1..469dd86 100644 --- a/src/app/shared/components/cart/cart.component.html +++ b/src/app/shared/components/cart/cart.component.html @@ -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 { diff --git a/src/app/shared/components/cart/cart.component.spec.ts b/src/app/shared/components/cart/cart.component.spec.ts index d7a1d49..bdf87d1 100644 --- a/src/app/shared/components/cart/cart.component.spec.ts +++ b/src/app/shared/components/cart/cart.component.spec.ts @@ -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); + }); }); diff --git a/src/app/shared/components/cart/cart.component.ts b/src/app/shared/components/cart/cart.component.ts index 7a78e16..c1f852c 100644 --- a/src/app/shared/components/cart/cart.component.ts +++ b/src/app/shared/components/cart/cart.component.ts @@ -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>({}); + protected readonly variantOverrides = signal>({}); 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); diff --git a/src/app/shared/components/product-row-card/product-row-card.component.html b/src/app/shared/components/product-row-card/product-row-card.component.html index 9e5f9c9..526b733 100644 --- a/src/app/shared/components/product-row-card/product-row-card.component.html +++ b/src/app/shared/components/product-row-card/product-row-card.component.html @@ -27,20 +27,11 @@
-
- @for (selector of variantSelectors(); track selector.key) { - - } -
+ diff --git a/src/app/shared/components/product-row-card/product-row-card.component.scss b/src/app/shared/components/product-row-card/product-row-card.component.scss index af30676..596a1e0 100644 --- a/src/app/shared/components/product-row-card/product-row-card.component.scss +++ b/src/app/shared/components/product-row-card/product-row-card.component.scss @@ -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 */ diff --git a/src/app/shared/components/product-row-card/product-row-card.component.ts b/src/app/shared/components/product-row-card/product-row-card.component.ts index 4dc23b5..8820183 100644 --- a/src/app/shared/components/product-row-card/product-row-card.component.ts +++ b/src/app/shared/components/product-row-card/product-row-card.component.ts @@ -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; -} - -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>({}); - protected readonly attributeKeys = computed(() => - Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))), - ); - protected readonly variantSelectors = computed(() => { - 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(); - - 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". */ diff --git a/src/app/shared/components/quantity-selector/quantity-selector.component.scss b/src/app/shared/components/quantity-selector/quantity-selector.component.scss index c8fe8f5..bb6199e 100644 --- a/src/app/shared/components/quantity-selector/quantity-selector.component.scss +++ b/src/app/shared/components/quantity-selector/quantity-selector.component.scss @@ -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 { diff --git a/src/app/shared/components/variant-selector/variant-selector.component.html b/src/app/shared/components/variant-selector/variant-selector.component.html new file mode 100644 index 0000000..571b3b2 --- /dev/null +++ b/src/app/shared/components/variant-selector/variant-selector.component.html @@ -0,0 +1,17 @@ +@if (selectors().length > 0) { +
+ @for (selector of selectors(); track selector.key) { + + } +
+} diff --git a/src/app/shared/components/variant-selector/variant-selector.component.scss b/src/app/shared/components/variant-selector/variant-selector.component.scss new file mode 100644 index 0000000..bf8049a --- /dev/null +++ b/src/app/shared/components/variant-selector/variant-selector.component.scss @@ -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; + } +} diff --git a/src/app/shared/components/variant-selector/variant-selector.component.spec.ts b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts new file mode 100644 index 0000000..2440b05 --- /dev/null +++ b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts @@ -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', + }); + }); +}); diff --git a/src/app/shared/components/variant-selector/variant-selector.component.ts b/src/app/shared/components/variant-selector/variant-selector.component.ts new file mode 100644 index 0000000..0eaaf9b --- /dev/null +++ b/src/app/shared/components/variant-selector/variant-selector.component.ts @@ -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; +} + +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([]); + readonly selectedVariant = model(null); + readonly disabled = input(false); + readonly compact = input(false); + + protected readonly selectedValues = signal>({}); + protected readonly attributeKeys = computed(() => + Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))), + ); + protected readonly selectors = computed(() => { + 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(); + + 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); + } +}