From 7c2a098696df63654fafe40635cff7f245f02575 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 10 Aug 2026 09:58:51 -0300 Subject: [PATCH] feat: enhance product variant handling and improve layout for better user interaction --- .../reutilizables-test-page.component.ts | 5 +- .../hero-banner/hero-banner.component.html | 2 +- .../product-list/product-list.component.ts | 2 +- .../product-row-card.component.html | 23 ++- .../product-row-card.component.scss | 11 +- .../product-row-card.component.ts | 148 ++++++++++++++++-- ...uct-vertical-with-cart-card.component.html | 25 +-- ...uct-vertical-with-cart-card.component.scss | 35 +---- ...-vertical-with-cart-card.component.spec.ts | 28 ++-- ...oduct-vertical-with-cart-card.component.ts | 3 - 10 files changed, 185 insertions(+), 97 deletions(-) diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index 09e3915..f133a64 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -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' } }, ], }; diff --git a/src/app/shared/components/hero-banner/hero-banner.component.html b/src/app/shared/components/hero-banner/hero-banner.component.html index 483de3d..79aea97 100644 --- a/src/app/shared/components/hero-banner/hero-banner.component.html +++ b/src/app/shared/components/hero-banner/hero-banner.component.html @@ -5,7 +5,7 @@ @if (heroConfig) { diff --git a/src/app/shared/components/product-list/product-list.component.ts b/src/app/shared/components/product-list/product-list.component.ts index ac12391..be3b3a3 100644 --- a/src/app/shared/components/product-list/product-list.component.ts +++ b/src/app/shared/components/product-list/product-list.component.ts @@ -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, })); } 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 53d8664..9e5f9c9 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 @@ -25,15 +25,22 @@ - +
- @if (variants().length > 0) { - - } +
+ @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 a5ee11a..af30676 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 @@ -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; 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 be4eba3..4dc23b5 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 @@ -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; +} + +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(1); - readonly selectedVariant = model(null); + readonly selectedVariant = model(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>({}); + 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(); - 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(); + + 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/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 2d03f1b..267dd4b 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 @@ -13,30 +13,9 @@ {{ formattedPrice() }}
- } @else if (!hasMultipleVariantSelectors()) { -
- {{ formattedPrice() }} - -
- @for (selector of variantSelectors(); track selector.key) { - - } - - -
-
} @else { -
-
+
+
{{ formattedPrice() }}
diff --git a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss index e30af70..00ad1f3 100644 --- a/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss +++ b/src/app/shared/components/product-vertical-with-cart-card/product-vertical-with-cart-card.component.scss @@ -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; 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 c6ff6f6..ef6fdca 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 @@ -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); }); 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 38f1719..c4a06e7 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 @@ -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(() => {