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 98810f6..7e69a81 100644 --- a/src/app/core/layout/store-layout/store-layout.component.ts +++ b/src/app/core/layout/store-layout/store-layout.component.ts @@ -12,6 +12,7 @@ import { findMenu } from '../../services/menu.utils'; import { CheckoutService } from '../../services/checkout.service'; import { ToastService } from '../../services/toast.service'; import { Category } from '../../services/tenant.interface'; +import { VariantAttributeValue } from '../../services/catalog/catalog.interface'; @Component({ selector: 'app-store-layout', @@ -80,7 +81,7 @@ export class StoreLayoutComponent implements OnInit { if (selectedVariant) { attributes = Object.entries(selectedVariant.values).map(([label, value]) => ({ label: this.formatAttributeLabel(label), - value: Array.isArray(value) ? value.join(', ') : value, + value: this.variantAttributeLabel(value), })); } @@ -103,6 +104,14 @@ export class StoreLayoutComponent implements OnInit { return label.charAt(0).toUpperCase() + label.slice(1); } + private variantAttributeLabel(value: VariantAttributeValue): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => (typeof item === 'string' ? item : item.label)).join(', '); + } + return value.label; + } + 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 3145a6c..bc5b8ea 100644 --- a/src/app/core/services/cart/cart.interface.ts +++ b/src/app/core/services/cart/cart.interface.ts @@ -1,3 +1,5 @@ +import { VariantAttributeValue } from '../catalog/catalog.interface'; + export interface CartItemProduct { nombre: string; imagen: string | null; @@ -8,7 +10,7 @@ export interface CartItemVariant { id: number; precio: string; stock_tecnico: number | null; - values: Record; + values: Record; } export interface CartItem { diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index 913546c..663c398 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -47,6 +47,17 @@ export interface ProductAttribute { export type InventoryPolicy = 'tracked' | 'unlimited'; +export interface VariantAttributeOptionValue { + value: string; + label: string; +} + +export type VariantAttributeValue = + | VariantAttributeOptionValue + | VariantAttributeOptionValue[] + | string + | string[]; + export interface CatalogItemVariant { id: number; descripcion?: string | null; @@ -59,7 +70,7 @@ export interface CatalogItemVariant { maximum_use_date?: string | null; effective_minimum_use_date?: string | null; effective_maximum_use_date?: string | null; - values: Record; + values: Record; } export interface SelectedCatalogItemVariant extends CatalogItemVariant { @@ -98,7 +109,7 @@ export interface CatalogFeaturedItemVariant { descripcion?: string | null; precio?: string; stock_tecnico: number | null; - values: Record; + values: Record; } export interface CatalogFeaturedItem { diff --git a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts index 3df839d..ca37395 100644 --- a/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts +++ b/src/app/features/store/components/product-attribute-selector/product-attribute-selector.component.ts @@ -14,6 +14,7 @@ import { InventoryPolicy, ProductAttribute, ProductAttributeOption, + VariantAttributeValue, } from '../../../../core/services/catalog/catalog.interface'; @Component({ @@ -198,7 +199,7 @@ export class ProductAttributeSelectorComponent { private getVariantAttributeValues( attribute: ProductAttribute, - variantAttributes: Record, + variantAttributes: Record, ): string[] { const normalizedCodigo = this.normalizeText(attribute.codigo); const normalizedNombre = this.normalizeText(attribute.nombre); @@ -207,7 +208,9 @@ export class ProductAttributeSelectorComponent { const normalizedKey = this.normalizeText(key); if (normalizedKey === normalizedCodigo || normalizedKey === normalizedNombre) { - return (Array.isArray(value) ? value : [value]).map((item) => this.normalizeText(item)); + return (Array.isArray(value) ? value : [value]).map((item) => + this.normalizeText(typeof item === 'string' ? item : item.value), + ); } } diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html index 267dd4b..ec3531d 100644 --- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.html @@ -28,8 +28,8 @@ [ngModel]="selectedValues()[selector.key]" (ngModelChange)="onVariantValueChange(selector.key, $event)" > - @for (option of selector.options; track option) { - + @for (option of selector.options; track option.key) { + } } diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts index ef6fdca..aa8dbbf 100644 --- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.spec.ts @@ -136,6 +136,24 @@ describe('ProductVerticalWithCartCardComponent', () => { expect(selectors?.querySelector('app-quantity-selector')).toBeNull(); }); + it('uses the variant value for selection and renders its label', async () => { + const fixture = await createComponent(); + fixture.componentRef.setInput('variants', [ + { + id: 10, + values: { event_date: { value: '2', label: '10/10/2026 · 09:00 a 18:00' } }, + }, + ]); + fixture.detectChanges(); + + const option = fixture.nativeElement.querySelector( + '.product-vertical-with-cart-card__variant-select option', + ) as HTMLOptionElement; + + expect(option.textContent?.trim()).toBe('10/10/2026 · 09:00 a 18:00'); + expect((fixture.componentInstance as any).selectedValues()).toEqual({ event_date: '2' }); + }); + it('places all variant selectors together below price and quantity', async () => { const fixture = await createComponent(); fixture.componentRef.setInput('variants', [ diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts index c4a06e7..19e5dc6 100644 --- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.ts @@ -13,18 +13,27 @@ import { FormsModule } from '@angular/forms'; import { ButtonComponent } from '../button/button.component'; import { QuantitySelectorComponent } from '../quantity-selector/quantity-selector.component'; +import { VariantAttributeValue } from '../../../core/services/catalog/catalog.interface'; + +type VariantSelectionValue = string | string[]; export interface VerticalCartVariant { id: number; descripcion?: string | null; precio?: string | number; - values: Record; + values: Record; +} + +interface VariantSelectorOption { + key: string; + value: VariantSelectionValue; + label: string; } interface VariantSelector { key: string; label: string; - options: string[]; + options: VariantSelectorOption[]; } @Component({ @@ -46,7 +55,7 @@ export class ProductVerticalWithCartCardComponent { readonly buy = output<{ quantity: number; variant: number | null }>(); readonly addToCart = output<{ quantity: number; variant: number | null }>(); - protected readonly selectedValues = signal>({}); + protected readonly selectedValues = signal>({}); protected readonly selectedVariantData = computed(() => this.variants().find((variant) => variant.id === this.selectedVariant()), @@ -67,13 +76,7 @@ export class ProductVerticalWithCartCardComponent { 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)), - ), - ), + options: this.optionsFor(variants, key), })); }); @@ -92,17 +95,21 @@ export class ProductVerticalWithCartCardComponent { const selected = variants.find((variant) => variant.id === this.selectedVariant()) ?? variants[0]; - this.selectedValues.set({ ...selected.values }); + this.selectedValues.set(this.selectionValues(selected.values)); this.selectedVariant.set(selected.id); }); }); } - protected onVariantValueChange(key: string, value: string): void { + protected onVariantValueChange(key: string, value: VariantSelectionValue): 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]), + selectors.every( + (selector) => + this.valueKey(this.selectionValue(variant.values[selector.key])) === + this.valueKey(values[selector.key]), + ), ); this.selectedValues.set(values); @@ -117,6 +124,55 @@ export class ProductVerticalWithCartCardComponent { this.buy.emit({ quantity: this.quantity(), variant: this.selectedVariant() }); } + private optionsFor(variants: VerticalCartVariant[], key: string): VariantSelectorOption[] { + const options = new Map(); + + for (const variant of variants) { + const attributeValue = variant.values[key]; + if (attributeValue === undefined) continue; + + const value = this.selectionValue(attributeValue); + const optionKey = this.valueKey(value); + if (!options.has(optionKey)) { + options.set(optionKey, { + key: optionKey, + value, + label: this.selectionLabel(attributeValue), + }); + } + } + + return Array.from(options.values()); + } + + private selectionValues( + values: Record, + ): Record { + return Object.fromEntries( + Object.entries(values).map(([key, value]) => [key, this.selectionValue(value)]), + ); + } + + private selectionValue(value: VariantAttributeValue): VariantSelectionValue { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => (typeof item === 'string' ? item : item.value)); + } + return value.value; + } + + private selectionLabel(value: VariantAttributeValue): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => (typeof item === 'string' ? item : item.label)).join(', '); + } + return value.label; + } + + private valueKey(value: VariantSelectionValue | undefined): string { + return JSON.stringify(value); + } + private formatVariantLabel(key: string): string { const label = key.replace(/[_-]+/g, ' '); return label.charAt(0).toUpperCase() + label.slice(1); 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 index 2440b05..0650008 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.spec.ts +++ b/src/app/shared/components/variant-selector/variant-selector.component.spec.ts @@ -38,4 +38,22 @@ describe('VariantSelectorComponent', () => { servicio: 'Cena', }); }); + + it('renders labels while matching variants by value', async () => { + await TestBed.configureTestingModule({ + imports: [VariantSelectorComponent], + }).compileComponents(); + const fixture = TestBed.createComponent(VariantSelectorComponent); + fixture.componentRef.setInput('variants', [ + { + value: 1, + values: { event_date: { value: '2', label: '10/10/2026 · 09:00 a 18:00' } }, + }, + ]); + fixture.detectChanges(); + + const option = fixture.nativeElement.querySelector('option') as HTMLOptionElement; + expect(option.textContent?.trim()).toBe('10/10/2026 · 09:00 a 18:00'); + expect((fixture.componentInstance as any).selectedValues()).toEqual({ event_date: '2' }); + }); }); diff --git a/src/app/shared/components/variant-selector/variant-selector.component.ts b/src/app/shared/components/variant-selector/variant-selector.component.ts index 0eaaf9b..78fa991 100644 --- a/src/app/shared/components/variant-selector/variant-selector.component.ts +++ b/src/app/shared/components/variant-selector/variant-selector.component.ts @@ -9,8 +9,9 @@ import { untracked, } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { VariantAttributeValue } from '../../../core/services/catalog/catalog.interface'; -export type VariantAttributeValue = string | string[]; +type VariantSelectionValue = string | string[]; export interface VariantSelectorVariant { value: unknown; @@ -20,7 +21,7 @@ export interface VariantSelectorVariant { interface VariantSelectorOption { key: string; label: string; - value: VariantAttributeValue; + value: VariantSelectionValue; } interface VariantSelectorGroup { @@ -43,7 +44,7 @@ export class VariantSelectorComponent { readonly disabled = input(false); readonly compact = input(false); - protected readonly selectedValues = signal>({}); + protected readonly selectedValues = signal>({}); protected readonly attributeKeys = computed(() => Array.from(new Set(this.variants().flatMap((variant) => Object.keys(variant.values)))), ); @@ -56,7 +57,10 @@ export class VariantSelectorComponent { const previousKeys = keys.slice(0, index); const compatibleVariants = variants.filter((variant) => previousKeys.every((previousKey) => - this.sameValue(variant.values[previousKey], selectedValues[previousKey]), + this.sameValue( + this.selectionValue(variant.values[previousKey]), + selectedValues[previousKey], + ), ), ); @@ -82,13 +86,13 @@ export class VariantSelectorComponent { const selected = variants.find((variant) => Object.is(variant.value, selectedVariant)) ?? variants[0]; - this.selectedValues.set({ ...selected.values }); + this.selectedValues.set(this.selectionValues(selected.values)); if (!Object.is(selected.value, selectedVariant)) this.selectedVariant.set(selected.value); }); }); } - protected onValueChange(key: string, value: VariantAttributeValue): void { + protected onValueChange(key: string, value: VariantSelectionValue): void { const variants = this.variants(); const keys = this.attributeKeys(); const changedIndex = keys.indexOf(key); @@ -99,7 +103,7 @@ export class VariantSelectorComponent { const previousKeys = keys.slice(0, index); const compatibleVariants = variants.filter((variant) => previousKeys.every((previousKey) => - this.sameValue(variant.values[previousKey], values[previousKey]), + this.sameValue(this.selectionValue(variant.values[previousKey]), values[previousKey]), ), ); const options = this.optionsFor(compatibleVariants, currentKey); @@ -113,7 +117,7 @@ export class VariantSelectorComponent { const matchingVariant = variants.find((variant) => keys.every((attributeKey) => - this.sameValue(variant.values[attributeKey], values[attributeKey]), + this.sameValue(this.selectionValue(variant.values[attributeKey]), values[attributeKey]), ), ); @@ -125,14 +129,19 @@ export class VariantSelectorComponent { const options = new Map(); for (const variant of variants) { - const value = variant.values[key]; - if (value === undefined || value === '') continue; + const attributeValue = variant.values[key]; + if (attributeValue === undefined) continue; + + const value = this.selectionValue(attributeValue); + if (value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) { + continue; + } const optionKey = this.valueKey(value); if (!options.has(optionKey)) { options.set(optionKey, { key: optionKey, - label: Array.isArray(value) ? value.join(', ') : value, + label: this.selectionLabel(attributeValue), value, }); } @@ -142,18 +151,48 @@ export class VariantSelectorComponent { } private sameValue( - left: VariantAttributeValue | undefined, - right: VariantAttributeValue | undefined, + left: VariantSelectionValue | undefined, + right: VariantSelectionValue | undefined, ): boolean { return ( left !== undefined && right !== undefined && this.valueKey(left) === this.valueKey(right) ); } - private valueKey(value: VariantAttributeValue): string { + private valueKey(value: VariantSelectionValue): string { return JSON.stringify(value); } + private selectionValues( + values: Record, + ): Record { + return Object.fromEntries( + Object.entries(values).flatMap(([key, value]) => { + const selectionValue = this.selectionValue(value); + return selectionValue === undefined ? [] : [[key, selectionValue]]; + }), + ); + } + + private selectionValue( + value: VariantAttributeValue | undefined, + ): VariantSelectionValue | undefined { + if (value === undefined) return undefined; + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => (typeof item === 'string' ? item : item.value)); + } + return value.value; + } + + private selectionLabel(value: VariantAttributeValue): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => (typeof item === 'string' ? item : item.label)).join(', '); + } + return value.label; + } + private formatVariantLabel(key: string): string { const label = key.replace(/[_-]+/g, ' '); return label.charAt(0).toUpperCase() + label.slice(1);