From c9f9e6cc3ecac07ab4769fece5450522c63e5554 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 5 Aug 2026 16:48:34 -0300 Subject: [PATCH] feat: enhance product vertical card with variant selection and improve store header responsiveness --- .../store-header/store-header.component.html | 6 +- .../store-header/store-header.component.scss | 48 ++++++++-- .../product-list/product-list.component.html | 1 + .../product-list.component.spec.ts | 25 +++++ .../product-list/product-list.component.ts | 19 +++- ...uct-vertical-with-cart-card.component.html | 69 ++++++++++++-- ...uct-vertical-with-cart-card.component.scss | 63 ++++++++++++- ...-vertical-with-cart-card.component.spec.ts | 66 ++++++++++++- ...oduct-vertical-with-cart-card.component.ts | 94 +++++++++++++++++-- .../store-section.component.scss | 2 +- 10 files changed, 362 insertions(+), 31 deletions(-) diff --git a/src/app/core/layout/store-layout/store-header/store-header.component.html b/src/app/core/layout/store-layout/store-header/store-header.component.html index f6578d4..54bc5ab 100644 --- a/src/app/core/layout/store-layout/store-header/store-header.component.html +++ b/src/app/core/layout/store-layout/store-header/store-header.component.html @@ -2,7 +2,7 @@
-
+
-
+
-
+
@if (ticketsMenu(); as menu) { diff --git a/src/app/shared/components/product-list/product-list.component.spec.ts b/src/app/shared/components/product-list/product-list.component.spec.ts index d70622e..30504d0 100644 --- a/src/app/shared/components/product-list/product-list.component.spec.ts +++ b/src/app/shared/components/product-list/product-list.component.spec.ts @@ -160,6 +160,31 @@ describe('ProductListComponent', () => { }); }); + it('passes column variants through to the purchase event', async () => { + const itemWithVariants: ProductListItem = { + ...items[0], + variants: [ + { id: 91, stock_tecnico: 3, values: { fecha: '10 de octubre' } }, + { id: 92, stock_tecnico: 4, values: { fecha: '11 de octubre' } }, + ], + }; + const fixture = await render('column_with_cart', [itemWithVariants]); + const buySpy = vi.fn(); + fixture.componentInstance.buy.subscribe(buySpy); + + const card = fixture.nativeElement.querySelector( + 'app-product-vertical-with-cart-card', + ) as HTMLElement; + (card.querySelector('.btn-primary') as HTMLButtonElement).click(); + + expect(buySpy).toHaveBeenCalledWith({ + product: itemWithVariants, + quantity: 1, + variant: 91, + directPurchase: true, + }); + }); + it('renders pagination and emits the requested page', async () => { const fixture = await render('row'); const pageChangeSpy = vi.fn(); 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 34382a4..f4e6681 100644 --- a/src/app/shared/components/product-list/product-list.component.ts +++ b/src/app/shared/components/product-list/product-list.component.ts @@ -123,8 +123,11 @@ export class ProductListComponent { }); } - protected emitColumnCart(product: ProductListItem, event: { quantity: number }): void { - this.addToCart.emit({ product, quantity: event.quantity }); + protected emitColumnCart( + product: ProductListItem, + event: { quantity: number; variant: number | null }, + ): void { + this.addToCart.emit({ product, quantity: event.quantity, variant: event.variant }); } protected emitRowBuy( @@ -139,8 +142,16 @@ export class ProductListComponent { }); } - protected emitColumnBuy(product: ProductListItem, event: { quantity: number }): void { - this.buy.emit({ product, quantity: event.quantity, variant: null, directPurchase: true }); + protected emitColumnBuy( + product: ProductListItem, + event: { quantity: number; variant: number | null }, + ): void { + this.buy.emit({ + product, + quantity: event.quantity, + variant: event.variant, + directPurchase: true, + }); } protected emitProductDetailBuy(product: ProductListItem): void { 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 d6000cb..d2c396f 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 @@ -8,14 +8,71 @@
-
- {{ formattedPrice() }} - -
+ @if (!hasVariants()) { +
+ {{ formattedPrice() }} + +
+ } @else if (!hasMultipleVariantSelectors()) { +
+ {{ formattedPrice() }} + +
+ @for (selector of variantSelectors(); track selector.key) { + + } + + +
+
+ } @else { +
+
+ {{ formattedPrice() }} + +
+ +
+ @for (selector of variantSelectors(); track selector.key) { + + } +
+
+ }
- Comprar - Agregar al carrito + + Comprar + + + Agregar al carrito +
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 23c4b01..64b0143 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 @@ -40,7 +40,7 @@ } &__description { - margin: 23px 0 ; + margin: 23px 0; color: #6f6f6f; font-size: 11px; font-weight: 300; @@ -60,6 +60,60 @@ padding-inline: 12px; } + &__single-variant { + display: grid; + justify-items: center; + gap: 16px; + } + + &__single-variant-row { + 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 { + min-width: 0; + height: 40px; + border-color: #d8d8d8; + color: #6f6f6f; + font-size: 12px; + + &:focus { + border-color: var(--tenant-primary, #009933); + box-shadow: 0 0 0 0.2rem color-mix(in srgb, transparent 75%, var(--tenant-primary, #009933)); + } + } + + &__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; @@ -87,6 +141,12 @@ } } +@media (max-width: 767.98px) { + .product-vertical-with-cart-card { + border-color: var(--color-primary, var(--tenant-primary, #009933)); + } +} + @media (max-width: 420px) { .product-vertical-with-cart-card { padding: 42px 20px 21px; @@ -98,6 +158,5 @@ &__summary { padding-inline: 12px; } - } } 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 ce0c4ba..fc2e1b2 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 @@ -83,7 +83,7 @@ describe('ProductVerticalWithCartCardComponent', () => { ) as HTMLButtonElement; button.click(); - expect(buySpy).toHaveBeenCalledWith({ quantity: 1 }); + expect(buySpy).toHaveBeenCalledWith({ quantity: 1, variant: null }); }); it('emits addToCart with the current quantity', async () => { @@ -98,7 +98,7 @@ describe('ProductVerticalWithCartCardComponent', () => { ) as HTMLButtonElement; button.click(); - expect(addToCartSpy).toHaveBeenCalledWith({ quantity: 3 }); + expect(addToCartSpy).toHaveBeenCalledWith({ quantity: 3, variant: null }); }); it('uses the shared primary and secondary buttons', async () => { @@ -115,4 +115,66 @@ describe('ProductVerticalWithCartCardComponent', () => { element.querySelector('.product-vertical-with-cart-card__actions .btn-outline-primary'), ).not.toBeNull(); }); + + it('places a single variant selector in the same row as the quantity', async () => { + const fixture = await createComponent(); + fixture.componentRef.setInput('variants', [ + { id: 10, values: { fecha: '10 de octubre' } }, + { id: 11, values: { fecha: '11 de octubre' } }, + ]); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + const row = element.querySelector('.product-vertical-with-cart-card__single-variant-row'); + + 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(); + }); + + it('uses separate summary and selector columns for multiple variant attributes', async () => { + const fixture = await createComponent(); + fixture.componentRef.setInput('variants', [ + { id: 20, values: { fecha: '10 de octubre', turno: 'Mañana' } }, + { id: 21, values: { fecha: '10 de octubre', turno: 'Tarde' } }, + ]); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + const layout = element.querySelector('.product-vertical-with-cart-card__multiple-variants'); + + expect( + layout?.querySelector('.product-vertical-with-cart-card__summary-column'), + ).not.toBeNull(); + expect( + layout?.querySelectorAll('.product-vertical-with-cart-card__variant-select'), + ).toHaveLength(2); + }); + + it('emits the selected variant with the purchase action', async () => { + const fixture = await createComponent(); + fixture.componentRef.setInput('variants', [ + { id: 30, values: { fecha: '10 de octubre' } }, + { id: 31, values: { fecha: '11 de octubre' } }, + ]); + fixture.detectChanges(); + const buySpy = vi.fn(); + fixture.componentInstance.buy.subscribe(buySpy); + + const select = fixture.nativeElement.querySelector( + '.product-vertical-with-cart-card__variant-select', + ) as HTMLSelectElement; + select.value = select.options[1].value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector( + '.product-vertical-with-cart-card__actions .btn-primary', + ) as HTMLButtonElement; + button.click(); + + expect(buySpy).toHaveBeenCalledWith({ quantity: 1, variant: 31 }); + }); }); 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 7ef0c11..876a7ac 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 @@ -1,11 +1,33 @@ -import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + 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 VerticalCartVariant { + id: number; + values: Record; +} + +interface VariantSelector { + key: string; + label: string; + options: string[]; +} + @Component({ selector: 'app-product-vertical-with-cart-card', - imports: [ButtonComponent, QuantitySelectorComponent], + imports: [ButtonComponent, FormsModule, QuantitySelectorComponent], templateUrl: './product-vertical-with-cart-card.component.html', styleUrl: './product-vertical-with-cart-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -14,20 +36,80 @@ export class ProductVerticalWithCartCardComponent { readonly title = input(''); readonly description = input(''); readonly price = input(0); + readonly variants = input([]); readonly quantity = model(1); + readonly selectedVariant = model(null); - readonly buy = output<{ quantity: number }>(); - readonly addToCart = output<{ quantity: number }>(); + readonly buy = output<{ quantity: number; variant: number | null }>(); + readonly addToCart = output<{ quantity: number; variant: number | null }>(); + + protected readonly selectedValues = signal>({}); protected readonly formattedPrice = computed(() => this.formatCurrency(this.price())); + protected readonly variantSelectors = computed(() => { + const variants = this.variants(); + const keys = Array.from(new Set(variants.flatMap((variant) => Object.keys(variant.values)))); + + 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)), + ), + ), + })); + }); + + protected readonly hasVariants = computed(() => this.variantSelectors().length > 0); + protected readonly hasMultipleVariantSelectors = computed( + () => this.variantSelectors().length > 1, + ); + + constructor() { + effect(() => { + const variants = this.variants(); + + untracked(() => { + if (variants.length === 0) { + this.selectedValues.set({}); + this.selectedVariant.set(null); + return; + } + + const selected = + variants.find((variant) => variant.id === this.selectedVariant()) ?? variants[0]; + this.selectedValues.set({ ...selected.values }); + this.selectedVariant.set(selected.id); + }); + }); + } + + protected onVariantValueChange(key: string, value: string): 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]), + ); + + this.selectedValues.set(values); + this.selectedVariant.set(matchingVariant?.id ?? null); + } protected onAddToCart(): void { - this.addToCart.emit({ quantity: this.quantity() }); + this.addToCart.emit({ quantity: this.quantity(), variant: this.selectedVariant() }); } protected onBuy(): void { - this.buy.emit({ quantity: this.quantity() }); + this.buy.emit({ quantity: this.quantity(), variant: this.selectedVariant() }); + } + + private formatVariantLabel(key: string): string { + const label = key.replace(/[_-]+/g, ' '); + return label.charAt(0).toUpperCase() + label.slice(1); } private formatCurrency(value: number): string { diff --git a/src/app/shared/components/store-section/store-section.component.scss b/src/app/shared/components/store-section/store-section.component.scss index e957c83..80f23e7 100644 --- a/src/app/shared/components/store-section/store-section.component.scss +++ b/src/app/shared/components/store-section/store-section.component.scss @@ -8,5 +8,5 @@ font-size: 15px; font-weight: 700; letter-spacing: 0.08em; - color: #000000; + color: var(--color-primary); }