From 3f8b5bf2064895b6d51ad206de902e50c8f80333 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 14 Aug 2026 15:44:30 -0300 Subject: [PATCH] feat(product-ticket-selector): enhance variant selection handling and add toast notifications for errors --- .../product-ticket-selector.component.spec.ts | 147 +++++++++++++++++- .../product-ticket-selector.component.ts | 119 +++++++++++--- 2 files changed, 246 insertions(+), 20 deletions(-) diff --git a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts index 1d31044..0a1116c 100644 --- a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts +++ b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts @@ -1,13 +1,15 @@ import '@angular/compiler'; +import { HttpErrorResponse } from '@angular/common/http'; import { signal } from '@angular/core'; import { TestBed, getTestBed } from '@angular/core/testing'; import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { CartService } from '../../../core/services/cart/cart.service'; import { CatalogService } from '../../../core/services/catalog/catalog.service'; import { ModalService } from '../../../core/services/modal.service'; +import { ToastService } from '../../../core/services/toast.service'; import { ProductTicketSelectorComponent } from './product-ticket-selector.component'; describe('ProductTicketSelectorComponent', () => { @@ -285,4 +287,147 @@ describe('ProductTicketSelectorComponent', () => { fixture.nativeElement.querySelector('.ticket-selector__validation').textContent, ).toContain('Validado'); }); + + it('keeps the reserved selection and shows a toast when a variant change cannot be completed', async () => { + const cartState = signal({ + id: 10, + tenant_codigo: 'demo', + status: 'active', + subtotal: '10000.00', + items: [ + { + id: 25, + cantidad: 1, + precio_unitario: '10000.00', + catalog_item_id: 7, + variant_id: 401, + product: { + nombre: 'Entrada', + imagen: null, + variants: [ + { + id: 401, + precio: '10000.00', + stock_tecnico: 0, + values: { seat: '1' }, + }, + ], + }, + }, + ], + }); + const selector = { + key: 'seat', + label: 'Asiento', + options: ['1', '2'], + enabled: true, + }; + const summary = { + valid: true, + available_variant_count: 2, + matching_variant_count: 1, + price_range: { minimum: '10000.00', maximum: '10000.00' }, + selectors: [selector], + }; + const catalogService = { + withoutLoading: vi.fn(), + getVariantOptions: vi + .fn() + .mockReturnValueOnce( + of({ + ...summary, + selected_values: { seat: '1' }, + resolved_variant: { + id: 401, + precio: '10000.00', + stock_tecnico: 0, + values: { seat: '1' }, + }, + }), + ) + .mockReturnValueOnce( + of({ + ...summary, + selected_values: { seat: '2' }, + resolved_variant: { + id: 402, + precio: '10000.00', + stock_tecnico: 1, + values: { seat: '2' }, + }, + }), + ), + }; + const errorMessage = 'La entrada seleccionada ya no está disponible.'; + const cartService = { + cart: cartState.asReadonly(), + withoutLoading: vi.fn(), + updateItemVariant: vi.fn().mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 422, + error: { message: errorMessage }, + }), + ), + ), + }; + const toastService = { danger: vi.fn() }; + catalogService.withoutLoading.mockReturnValue(catalogService); + cartService.withoutLoading.mockReturnValue(cartService); + + await TestBed.configureTestingModule({ + imports: [ProductTicketSelectorComponent], + providers: [ + { provide: CatalogService, useValue: catalogService }, + { provide: CartService, useValue: cartService }, + { provide: ToastService, useValue: toastService }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(ProductTicketSelectorComponent); + fixture.componentRef.setInput('productId', 7); + fixture.componentRef.setInput('title', 'Entrada'); + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentInstance['onSelectionChange'](1, 'seat', '2'); + fixture.detectChanges(); + + expect(cartService.updateItemVariant).toHaveBeenCalledWith(25, 1, 402); + expect(toastService.danger).toHaveBeenCalledWith(errorMessage); + expect(fixture.componentInstance['rows']()[0]).toMatchObject({ + variantId: 401, + reservedVariantId: 401, + selectedValues: { seat: '1' }, + status: 'reserved', + error: null, + }); + const select = fixture.nativeElement.querySelector('select') as HTMLSelectElement; + expect(select.value).toBe(JSON.stringify('1')); + + const unavailableMessage = 'La combinación seleccionada ya no está disponible.'; + catalogService.getVariantOptions.mockReturnValueOnce( + of({ + ...summary, + valid: false, + matching_variant_count: 0, + selected_values: {}, + resolved_variant: null, + }), + ); + + fixture.componentInstance['onSelectionChange'](1, 'seat', '2'); + fixture.detectChanges(); + + expect(cartService.updateItemVariant).toHaveBeenCalledTimes(1); + expect(toastService.danger).toHaveBeenLastCalledWith(unavailableMessage); + expect(fixture.componentInstance['rows']()[0]).toMatchObject({ + variantId: 401, + reservedVariantId: 401, + selectedValues: { seat: '1' }, + status: 'reserved', + error: null, + }); + expect(select.value).toBe(JSON.stringify('1')); + }); }); diff --git a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.ts b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.ts index f9db4cf..8040799 100644 --- a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.ts +++ b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.ts @@ -18,6 +18,7 @@ import { Subscription } from 'rxjs'; import { CartService } from '../../../core/services/cart/cart.service'; import { CartItem, CartItemVariantValue } from '../../../core/services/cart/cart.interface'; import { ModalService } from '../../../core/services/modal.service'; +import { ToastService } from '../../../core/services/toast.service'; import { CatalogVariantOptionsResponse, CatalogVariantSelector, @@ -44,6 +45,8 @@ interface TicketSelectionRow { cartItemId: number | null; selectedValues: Record; selectors: CatalogVariantSelector[]; + reservedSelectedValues: Record; + reservedSelectors: CatalogVariantSelector[]; status: TicketSelectionStatus; error: string | null; } @@ -59,6 +62,7 @@ export class ProductTicketSelectorComponent { private readonly catalogService = inject(CatalogService); private readonly cartService = inject(CartService); private readonly modalService = inject(ModalService); + private readonly toastService = inject(ToastService); private readonly destroyRef = inject(DestroyRef); private readonly optionRequests = new Map(); private readonly reservationRequests = new Map(); @@ -222,7 +226,12 @@ export class ProductTicketSelectorComponent { if (value === null) delete selectedValues[selectorKey]; else selectedValues[selectorKey] = this.normalizeValue(value); - this.loadOptions(rowId, selectedValues); + const preservesReservedSelection = + row.reservedVariantId !== null && + row.selectors.length > 0 && + row.selectors.every(({ key }) => key in selectedValues); + + this.loadOptions(rowId, selectedValues, preservesReservedSelection); } protected optionLabel(value: CatalogVariantValue): string { @@ -259,12 +268,20 @@ export class ProductTicketSelectorComponent { return this.disabled() || this.isBusy(row); } - private loadOptions(rowId: number, selectedValues: Record): void { + private loadOptions( + rowId: number, + selectedValues: Record, + preserveReservedSelection = false, + ): void { const row = this.findRow(rowId); if (!row) return; this.optionRequests.get(rowId)?.unsubscribe(); - this.patchRow(rowId, { selectedValues, status: 'checking', error: null }); + this.patchRow(rowId, { + selectedValues: preserveReservedSelection ? row.reservedSelectedValues : selectedValues, + status: 'checking', + error: null, + }); const request = this.catalogService .withoutLoading() @@ -277,6 +294,15 @@ export class ProductTicketSelectorComponent { this.applySummary(response, row); if (!response.valid) { + if (row.reservedVariantId !== null) { + this.restoreReservedRowAfterError( + rowId, + row, + 'La combinación seleccionada ya no está disponible.', + ); + return; + } + this.patchRow(rowId, { variantId: null, selectedValues: {}, @@ -292,35 +318,61 @@ export class ProductTicketSelectorComponent { this.patchRow(rowId, { variantId: response.resolved_variant?.id ?? null, - selectedValues: response.selected_values, - selectors: response.selectors, + selectedValues: preserveReservedSelection + ? row.reservedSelectedValues + : response.selected_values, + selectors: preserveReservedSelection ? row.reservedSelectors : response.selectors, status: 'selecting', error: null, }); if (response.resolved_variant !== null) { if (row.cartItemId !== null && row.reservedVariantId === response.resolved_variant.id) { - this.patchRow(rowId, { status: 'reserved' }); + this.patchRow(rowId, { + reservedSelectedValues: response.selected_values, + reservedSelectors: response.selectors, + status: 'reserved', + }); } else { - this.reserveRow(rowId, response.resolved_variant.id); + this.reserveRow( + rowId, + response.resolved_variant.id, + response.selected_values, + response.selectors, + ); } } }, error: (error: HttpErrorResponse) => { - this.patchRow(rowId, { - status: 'error', - error: this.errorMessage(error, 'No se pudo consultar la disponibilidad.'), - }); + const message = this.errorMessage(error, 'No se pudo consultar la disponibilidad.'); + + if (row.reservedVariantId !== null) { + this.restoreReservedRowAfterError(rowId, row, message); + return; + } + + this.patchRow(rowId, { status: 'error', error: message }); + this.toastService.danger(message); }, }); this.optionRequests.set(rowId, request); } - private reserveRow(rowId: number, variantId: number): void { + private reserveRow( + rowId: number, + variantId: number, + selectedValues: Record, + selectors: CatalogVariantSelector[], + ): void { const row = this.findRow(rowId); if (!row || (row.status === 'reserved' && row.reservedVariantId === variantId)) return; - this.patchRow(rowId, { status: 'reserving', error: null }); + this.patchRow(rowId, { + selectedValues: row.reservedVariantId === null ? selectedValues : row.reservedSelectedValues, + selectors: row.reservedVariantId === null ? selectors : row.reservedSelectors, + status: 'reserving', + error: null, + }); const cartService = this.cartService.withoutLoading(); const operation = row.cartItemId === null @@ -348,6 +400,10 @@ export class ProductTicketSelectorComponent { this.patchRow(rowId, { variantId, reservedVariantId: variantId, + selectedValues, + selectors, + reservedSelectedValues: selectedValues, + reservedSelectors: selectors, cartItemId: cartItem.id, status: 'reserved', error: null, @@ -357,16 +413,35 @@ export class ProductTicketSelectorComponent { } }, error: (error: HttpErrorResponse) => { - this.patchRow(rowId, { - variantId: row.reservedVariantId, - status: row.reservedVariantId === null ? 'error' : 'reserved', - error: this.errorMessage(error, 'La entrada ya no está disponible.'), - }); + const message = this.errorMessage(error, 'La entrada ya no está disponible.'); + + if (row.reservedVariantId !== null) { + this.restoreReservedRowAfterError(rowId, row, message); + return; + } + + this.patchRow(rowId, { variantId: null, status: 'error', error: message }); + this.toastService.danger(message); }, }); this.reservationRequests.set(rowId, request); } + private restoreReservedRowAfterError( + rowId: number, + row: TicketSelectionRow, + message: string, + ): void { + this.patchRow(rowId, { + variantId: row.reservedVariantId, + selectedValues: row.reservedSelectedValues, + selectors: row.reservedSelectors, + status: 'reserved', + error: null, + }); + this.toastService.danger(message); + } + private applySummary(response: CatalogVariantOptionsResponse, row: TicketSelectionRow): void { this.availableVariantCount.set( Math.max(0, response.available_variant_count - (row.cartItemId === null ? 0 : 1)), @@ -385,6 +460,8 @@ export class ProductTicketSelectorComponent { cartItemId: null, selectedValues: {}, selectors: [], + reservedSelectedValues: {}, + reservedSelectors: [], status: 'checking', error: null, }; @@ -396,13 +473,17 @@ export class ProductTicketSelectorComponent { const variant = item.product?.variants?.find(({ id }) => id === item.variant_id); if (!variant) return null; + const selectedValues = this.normalizeCartValues(variant.values); + return { id, variantId: item.variant_id, reservedVariantId: item.variant_id, cartItemId: item.id, - selectedValues: this.normalizeCartValues(variant.values), + selectedValues, selectors: [], + reservedSelectedValues: selectedValues, + reservedSelectors: [], status: 'checking', error: null, };