From 97250d10b7020a35696f3fb173ebfee951877021 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 14 Aug 2026 09:43:53 -0300 Subject: [PATCH] feat(ticket-selector): reserve completed selections in cart --- .../reutilizables-test-page.component.html | 1 + .../product-list/product-list.component.html | 1 + .../product-list.component.spec.ts | 25 +- .../product-list/product-list.component.ts | 2 + .../product-ticket-selector.component.html | 13 +- .../product-ticket-selector.component.scss | 14 + .../product-ticket-selector.component.spec.ts | 113 +++++++ .../product-ticket-selector.component.ts | 284 ++++++++++++++++-- 8 files changed, 426 insertions(+), 27 deletions(-) create mode 100644 src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html index 002c96e..c5b4107 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html @@ -767,6 +767,7 @@

{ productItems: CatalogFeaturedItems = paginatedItems(), groupLayout: CatalogGroupLayout = 'paginated', ) { - await TestBed.configureTestingModule({ imports: [ProductListComponent] }).compileComponents(); + await TestBed.configureTestingModule({ + imports: [ProductListComponent], + providers: [ + { provide: CatalogService, useValue: {} }, + { provide: CartService, useValue: {} }, + ], + }).compileComponents(); const fixture = TestBed.createComponent(ProductListComponent); fixture.componentRef.setInput('layout', layout); fixture.componentRef.setInput('groupLayout', groupLayout); @@ -265,7 +273,12 @@ describe('ProductListComponent', () => { const ticketSelector = fixture.debugElement.query(By.directive(ProductTicketSelectorComponent)) .componentInstance as ProductTicketSelectorComponent; - ticketSelector['updateRow'](1, 401); + ticketSelector['patchRow'](1, { + variantId: 401, + reservedVariantId: 401, + cartItemId: 1, + status: 'reserved', + }); fixture.detectChanges(); (element.querySelector('.ticket-selector__add-row-button') as HTMLButtonElement).click(); @@ -274,7 +287,12 @@ describe('ProductListComponent', () => { expect(element.querySelectorAll('.ticket-selector__row')).toHaveLength(2); expect(element.querySelectorAll('.variant-selector__select')).toHaveLength(8); - ticketSelector['updateRow'](2, 402); + ticketSelector['patchRow'](2, { + variantId: 402, + reservedVariantId: 402, + cartItemId: 2, + status: 'reserved', + }); fixture.detectChanges(); (element.querySelector('.ticket-selector__actions .btn-primary') as HTMLButtonElement).click(); @@ -285,6 +303,7 @@ describe('ProductListComponent', () => { variant: 401, variantIds: [401, 402], directPurchase: true, + reservedInCart: true, }); }); 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 8865653..96de302 100644 --- a/src/app/shared/components/product-list/product-list.component.ts +++ b/src/app/shared/components/product-list/product-list.component.ts @@ -41,6 +41,7 @@ export interface ProductListBuyEvent { variant?: number | null; variantIds?: number[]; directPurchase: boolean; + reservedInCart?: boolean; } @Component({ @@ -159,6 +160,7 @@ export class ProductListComponent { variant: variantIds[0] ?? null, variantIds, directPurchase: true, + reservedInCart: true, }); } } diff --git a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.html b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.html index 6a0fb27..0ab5838 100644 --- a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.html +++ b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.html @@ -25,17 +25,24 @@ + @if (rowStatus(row); as status) { + {{ status }} + } + @if (row.error; as error) { + {{ error }} + } } diff --git a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.scss b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.scss index 72774f0..a58bc49 100644 --- a/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.scss +++ b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.scss @@ -64,6 +64,20 @@ gap: 0.5rem; } + &__row-status, + &__row-error { + grid-column: 1 / -1; + font-size: 13px; + } + + &__row-status { + color: var(--success-color, #198754); + } + + &__row-error { + color: var(--danger-color, #dc3545); + } + &__fields { min-width: 0; 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 new file mode 100644 index 0000000..08038fe --- /dev/null +++ b/src/app/shared/components/product-ticket-selector/product-ticket-selector.component.spec.ts @@ -0,0 +1,113 @@ +import '@angular/compiler'; +import { TestBed, getTestBed } from '@angular/core/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { of } 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 { ProductTicketSelectorComponent } from './product-ticket-selector.component'; + +describe('ProductTicketSelectorComponent', () => { + beforeAll(() => { + try { + getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); + } catch { + // Test environment may already be initialized by another setup entrypoint. + } + }); + + afterEach(() => TestBed.resetTestingModule()); + + it('checks partial selections and reserves the resolved variant in the cart', async () => { + const variants = [ + { + id: 401, + precio: '10000.00', + stock_tecnico: 1, + values: { sector: { value: 'a', label: 'Sector A' }, seat: '1' }, + }, + ]; + const catalogService = { + getVariantOptions: vi + .fn() + .mockReturnValueOnce( + of({ + variants, + options: { seat: ['1'] }, + selected_values: { sector: 'a' }, + resolved_variant_id: null, + valid: true, + }), + ) + .mockReturnValueOnce( + of({ + variants, + options: { seat: ['1'] }, + selected_values: { sector: 'a', seat: '1' }, + resolved_variant_id: 401, + valid: true, + }), + ), + }; + const cartService = { + addItem: vi.fn().mockReturnValue( + of({ + data: { + 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: null, + }, + ], + }, + }), + ), + }; + await TestBed.configureTestingModule({ + imports: [ProductTicketSelectorComponent], + providers: [ + { provide: CatalogService, useValue: catalogService }, + { provide: CartService, useValue: cartService }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(ProductTicketSelectorComponent); + fixture.componentRef.setInput('productId', 7); + fixture.componentRef.setInput('title', 'Entrada'); + fixture.componentRef.setInput('variants', variants); + fixture.detectChanges(); + + fixture.componentInstance['onSelectionChange'](1, { + values: { sector: { value: 'a', label: 'Sector A' } }, + selectedVariant: null, + }); + + expect(catalogService.getVariantOptions).toHaveBeenLastCalledWith(7, { + selected_values: { sector: 'a' }, + excluded_variant_ids: [], + cart_item_id: null, + }); + expect(fixture.componentInstance['rows']()[0].status).toBe('selecting'); + + fixture.componentInstance['onSelectionChange'](1, { + values: { sector: { value: 'a', label: 'Sector A' }, seat: '1' }, + selectedVariant: 401, + }); + + expect(cartService.addItem).toHaveBeenCalledWith(7, 401, 1); + expect(fixture.componentInstance['rows']()[0]).toMatchObject({ + variantId: 401, + reservedVariantId: 401, + cartItemId: 25, + status: 'reserved', + }); + }); +}); 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 4cb66d1..dc3fd46 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 @@ -2,27 +2,51 @@ import { ChangeDetectionStrategy, Component, computed, + DestroyRef, effect, + inject, input, output, signal, } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Subscription } from 'rxjs'; +import { CartService } from '../../../core/services/cart/cart.service'; +import { CatalogService } from '../../../core/services/catalog/catalog.service'; import { ButtonComponent } from '../button/button.component'; import { IconButtonComponent } from '../icon-button/icon-button.component'; import { + VariantAttributeScalar, + VariantAttributeValue, VariantSelectorComponent, + VariantSelectorSelectionChange, VariantSelectorVariant, } from '../variant-selector/variant-selector.component'; export interface TicketSelectorVariant extends VariantSelectorVariant { + id: number; precio?: string | number; stock_tecnico?: number | null; } +type TicketSelectionStatus = + | 'selecting' + | 'checking' + | 'reserving' + | 'reserved' + | 'removing' + | 'error'; + interface TicketSelectionRow { id: number; variantId: number | null; + reservedVariantId: number | null; + cartItemId: number | null; + selectedValues: Record; + remoteVariants: TicketSelectorVariant[] | null; + status: TicketSelectionStatus; + error: string | null; } @Component({ @@ -33,6 +57,13 @@ interface TicketSelectionRow { changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProductTicketSelectorComponent { + private readonly catalogService = inject(CatalogService); + private readonly cartService = inject(CartService); + private readonly destroyRef = inject(DestroyRef); + private readonly optionRequests = new Map(); + private readonly reservationRequests = new Map(); + + readonly productId = input.required(); readonly title = input.required(); readonly description = input(''); readonly price = input(0); @@ -43,21 +74,30 @@ export class ProductTicketSelectorComponent { readonly buy = output(); - protected readonly rows = signal([{ id: 1, variantId: null }]); + protected readonly rows = signal([this.createRow(1)]); private nextRowId = 2; protected readonly selectableVariants = computed(() => this.variants().filter( (variant) => - !this.unavailableVariantIds().has(variant.id as number) && + !this.unavailableVariantIds().has(variant.id) && (variant.stock_tecnico == null || variant.stock_tecnico > 0), ), ); protected readonly hasSelection = computed( - () => this.rows().length > 0 && this.rows().every((row) => row.variantId !== null), + () => + this.rows().length > 0 && + this.rows().every( + (row) => + row.status === 'reserved' && + row.variantId !== null && + row.variantId === row.reservedVariantId, + ), ); protected readonly canAddRow = computed( - () => this.rows().length < this.selectableVariants().length, + () => + this.rows().length < this.selectableVariants().length && + !this.rows().some((row) => this.isBusy(row)), ); protected readonly priceRange = computed(() => { const prices = this.selectableVariants() @@ -82,56 +122,258 @@ export class ProductTicketSelectorComponent { effect(() => { const unavailable = this.unavailableVariantIds(); - if (!this.rows().some((row) => row.variantId !== null && unavailable.has(row.variantId))) { - return; - } - this.rows.update((rows) => rows.map((row) => - row.variantId !== null && unavailable.has(row.variantId) - ? { ...row, variantId: null } + row.cartItemId === null && row.variantId !== null && unavailable.has(row.variantId) + ? { + ...row, + variantId: null, + status: 'error', + error: 'La entrada seleccionada ya no está disponible.', + } : row, ), ); }); + + this.destroyRef.onDestroy(() => { + this.optionRequests.forEach((request) => request.unsubscribe()); + this.reservationRequests.forEach((request) => request.unsubscribe()); + }); } protected onBuy(): void { const variantIds = this.rows().flatMap((row) => - row.variantId === null ? [] : [row.variantId], + row.reservedVariantId === null ? [] : [row.reservedVariantId], ); - if (variantIds.length === this.rows().length && variantIds.length > 0) { + if (this.hasSelection() && variantIds.length > 0) { this.buy.emit(variantIds); } } protected addRow(): void { if (!this.canAddRow()) return; - this.rows.update((rows) => [...rows, { id: this.nextRowId++, variantId: null }]); + this.rows.update((rows) => [...rows, this.createRow(this.nextRowId++)]); } protected removeRow(rowId: number): void { - this.rows.update((rows) => rows.filter((row) => row.id !== rowId)); + const row = this.findRow(rowId); + if (!row || this.isBusy(row)) return; + + this.optionRequests.get(rowId)?.unsubscribe(); + + if (row.cartItemId === null) { + this.deleteRow(rowId); + return; + } + + this.patchRow(rowId, { status: 'removing', error: null }); + const request = this.cartService.removeItem(row.cartItemId).subscribe({ + next: () => this.deleteRow(rowId), + error: (error: HttpErrorResponse) => { + this.patchRow(rowId, { + status: 'reserved', + error: this.errorMessage(error, 'No se pudo liberar la entrada.'), + }); + }, + }); + this.reservationRequests.set(rowId, request); } - protected updateRow(rowId: number, selectedVariant: unknown): void { - const variantId = typeof selectedVariant === 'number' ? selectedVariant : null; - this.rows.update((rows) => rows.map((row) => (row.id === rowId ? { ...row, variantId } : row))); + protected updateCandidate(rowId: number, selectedVariant: unknown): void { + this.patchRow(rowId, { + variantId: typeof selectedVariant === 'number' ? selectedVariant : null, + }); + } + + protected onSelectionChange(rowId: number, event: VariantSelectorSelectionChange): void { + const row = this.findRow(rowId); + if (!row || this.isBusy(row)) return; + + const selectedValues = this.normalizeSelections(event.values); + this.optionRequests.get(rowId)?.unsubscribe(); + this.patchRow(rowId, { + selectedValues, + status: 'checking', + error: null, + }); + + const excludedVariantIds = this.rows() + .filter((candidate) => candidate.id !== rowId) + .flatMap((candidate) => { + const variantId = candidate.reservedVariantId ?? candidate.variantId; + return variantId === null ? [] : [variantId]; + }); + const request = this.catalogService + .getVariantOptions(this.productId(), { + selected_values: selectedValues, + excluded_variant_ids: excludedVariantIds, + cart_item_id: row.cartItemId, + }) + .subscribe({ + next: (response) => { + const variants = response.variants as TicketSelectorVariant[]; + + if (!response.valid) { + this.patchRow(rowId, { + variantId: null, + remoteVariants: variants, + status: 'error', + error: 'La combinación seleccionada ya no está disponible.', + }); + return; + } + + this.patchRow(rowId, { + variantId: response.resolved_variant_id, + remoteVariants: variants, + status: 'selecting', + error: null, + }); + + if (response.resolved_variant_id !== null) { + this.reserveRow(rowId, response.resolved_variant_id); + } + }, + error: (error: HttpErrorResponse) => { + this.patchRow(rowId, { + status: 'error', + error: this.errorMessage(error, 'No se pudo consultar la disponibilidad.'), + }); + }, + }); + this.optionRequests.set(rowId, request); } protected variantsForRow(rowId: number): VariantSelectorVariant[] { + const row = this.findRow(rowId); const selectedByOtherRows = new Set( this.rows() - .filter((row) => row.id !== rowId && row.variantId !== null) - .map((row) => row.variantId), + .filter((candidate) => candidate.id !== rowId) + .flatMap((candidate) => { + const variantId = candidate.reservedVariantId ?? candidate.variantId; + return variantId === null ? [] : [variantId]; + }), ); + const variants = row?.remoteVariants ?? this.selectableVariants(); - return this.selectableVariants().filter( - (variant) => !selectedByOtherRows.has(variant.id as number), + return variants.filter((variant) => !selectedByOtherRows.has(variant.id as number)); + } + + protected rowDisabled(row: TicketSelectionRow): boolean { + return this.disabled() || this.isBusy(row); + } + + protected rowStatus(row: TicketSelectionRow): string | null { + if (row.status === 'checking') return 'Consultando disponibilidad…'; + if (row.status === 'reserving') return 'Reservando entrada…'; + if (row.status === 'removing') return 'Liberando entrada…'; + if (row.status === 'reserved' && row.error === null) return 'Entrada reservada'; + + return null; + } + + private reserveRow(rowId: number, variantId: number): void { + const row = this.findRow(rowId); + if (!row || (row.status === 'reserved' && row.reservedVariantId === variantId)) return; + + this.patchRow(rowId, { status: 'reserving', error: null }); + const operation = + row.cartItemId === null + ? this.cartService.addItem(this.productId(), variantId, 1) + : this.cartService.updateItemVariant(row.cartItemId, 1, variantId); + const request = operation.subscribe({ + next: (response) => { + const cartItem = + row.cartItemId === null + ? response.data.items.find( + (item) => + item.catalog_item_id === this.productId() && item.variant_id === variantId, + ) + : response.data.items.find((item) => item.id === row.cartItemId); + + if (!cartItem) { + this.patchRow(rowId, { + variantId: row.reservedVariantId, + status: row.reservedVariantId === null ? 'error' : 'reserved', + error: 'No se pudo identificar la entrada reservada.', + }); + return; + } + + this.patchRow(rowId, { + variantId, + reservedVariantId: variantId, + cartItemId: cartItem.id, + status: 'reserved', + error: null, + }); + }, + 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.'), + }); + }, + }); + this.reservationRequests.set(rowId, request); + } + + private createRow(id: number): TicketSelectionRow { + return { + id, + variantId: null, + reservedVariantId: null, + cartItemId: null, + selectedValues: {}, + remoteVariants: null, + status: 'selecting', + error: null, + }; + } + + private findRow(rowId: number): TicketSelectionRow | undefined { + return this.rows().find((row) => row.id === rowId); + } + + private patchRow(rowId: number, patch: Partial): void { + this.rows.update((rows) => rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row))); + } + + private deleteRow(rowId: number): void { + this.rows.update((rows) => rows.filter((row) => row.id !== rowId)); + this.optionRequests.delete(rowId); + this.reservationRequests.delete(rowId); + } + + private isBusy(row: TicketSelectionRow): boolean { + return ['checking', 'reserving', 'removing'].includes(row.status); + } + + private normalizeSelections( + selections: Record, + ): Record { + return Object.fromEntries( + Object.entries(selections).map(([key, value]) => [key, this.normalizeValue(value)]), ); } + private normalizeValue(value: VariantAttributeValue): string | string[] { + return Array.isArray(value) + ? value.map((item) => this.scalarValue(item)) + : this.scalarValue(value); + } + + private scalarValue(value: VariantAttributeScalar): string { + return typeof value === 'string' ? value : value.value; + } + + private errorMessage(error: HttpErrorResponse, fallback: string): string { + return typeof error.error?.message === 'string' ? error.error.message : fallback; + } + private formatCurrency(value: number): string { return new Intl.NumberFormat('es-AR', { style: 'currency',