diff --git a/src/app/core/services/catalog/catalog.interface.ts b/src/app/core/services/catalog/catalog.interface.ts index 3ae65e1..e586e42 100644 --- a/src/app/core/services/catalog/catalog.interface.ts +++ b/src/app/core/services/catalog/catalog.interface.ts @@ -118,6 +118,7 @@ export interface CatalogFeaturedItemVariant { } export interface CatalogVariantOptionsResponse { + variants: CatalogFeaturedItemVariant[]; selectors: CatalogVariantSelector[]; selected_values: Record; resolved_variant: CatalogFeaturedItemVariant | null; diff --git a/src/app/features/store/pages/category-items-page/category-items-page.component.html b/src/app/features/store/pages/category-items-page/category-items-page.component.html index 3d6c403..d67a2c5 100644 --- a/src/app/features/store/pages/category-items-page/category-items-page.component.html +++ b/src/app/features/store/pages/category-items-page/category-items-page.component.html @@ -17,6 +17,7 @@ [layout]="categoryResults.layout" [groupLayout]="paginatedLayout" [items]="categoryResults" + [savingProductIds]="savingProductIds()" (buy)="onBuyProduct($event)" (addToCart)="onAddToCart($event)" (pageChange)="onPageChange($event)" diff --git a/src/app/features/store/pages/category-items-page/category-items-page.component.ts b/src/app/features/store/pages/category-items-page/category-items-page.component.ts index b1b50d9..eec8a59 100644 --- a/src/app/features/store/pages/category-items-page/category-items-page.component.ts +++ b/src/app/features/store/pages/category-items-page/category-items-page.component.ts @@ -9,7 +9,16 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; -import { catchError, combineLatest, distinctUntilChanged, map, of, switchMap, tap } from 'rxjs'; +import { + catchError, + combineLatest, + distinctUntilChanged, + finalize, + map, + of, + switchMap, + tap, +} from 'rxjs'; import { AuthService } from '../../../../core/services/auth/auth.service'; import { CartService } from '../../../../core/services/cart/cart.service'; @@ -52,6 +61,7 @@ export class CategoryItemsPageComponent { protected readonly results = signal(null); protected readonly error = signal(null); protected readonly creatingDirectPurchase = signal(false); + protected readonly savingProductIds = signal>(new Set()); protected readonly paginatedLayout: CatalogGroupLayout = 'paginated'; constructor() { @@ -157,14 +167,31 @@ export class CategoryItemsPageComponent { } protected onAddToCart(event: ProductListCartEvent): void { - this.cartService.addItem(event.product.id, event.variant ?? null, event.quantity).subscribe({ - next: (response) => { - this.toastService.success(response.message || 'Producto agregado al carrito'); - }, - error: (error: HttpErrorResponse) => { - const message = error.error?.message || 'No se pudo agregar el producto al carrito.'; - this.toastService.danger(message); - }, + const productId = event.product.id; + if (this.savingProductIds().has(productId)) { + return; + } + + this.setProductSaving(productId, true); + this.cartService + .addItem(productId, event.variant ?? null, event.quantity) + .pipe(finalize(() => this.setProductSaving(productId, false))) + .subscribe({ + next: (response) => { + this.toastService.success(response.message || 'Producto agregado al carrito'); + }, + error: (error: HttpErrorResponse) => { + const message = error.error?.message || 'No se pudo agregar el producto al carrito.'; + this.toastService.danger(message); + }, + }); + } + + private setProductSaving(productId: number, saving: boolean): void { + this.savingProductIds.update((current) => { + const updated = new Set(current); + saving ? updated.add(productId) : updated.delete(productId); + return updated; }); } diff --git a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html index 6905dac..ff6b6ce 100644 --- a/src/app/features/store/pages/product-detail-page/product-detail-page.component.html +++ b/src/app/features/store/pages/product-detail-page/product-detail-page.component.html @@ -63,7 +63,9 @@ [disabled]="!selectedVariantAvailable() || variantLoading() || addingToCart()" (click)="addToCart()" > - @if (variantLoading() || addingToCart()) { + @if (addingToCart()) { + Guardando + } @else if (variantLoading()) {
} @else { Agregar al carrito diff --git a/src/app/features/store/pages/search-page/search-page.component.html b/src/app/features/store/pages/search-page/search-page.component.html index d007831..b2420ac 100644 --- a/src/app/features/store/pages/search-page/search-page.component.html +++ b/src/app/features/store/pages/search-page/search-page.component.html @@ -20,6 +20,7 @@ [layout]="productLayout()" [groupLayout]="groupLayout()" [items]="displayItems()" + [savingProductIds]="savingProductIds()" (buy)="onBuyProduct($event)" (addToCart)="onAddToCart($event)" (pageChange)="onPageChange($event)" diff --git a/src/app/features/store/pages/search-page/search-page.component.ts b/src/app/features/store/pages/search-page/search-page.component.ts index 613c4e9..0a1ac9d 100644 --- a/src/app/features/store/pages/search-page/search-page.component.ts +++ b/src/app/features/store/pages/search-page/search-page.component.ts @@ -9,7 +9,7 @@ import { signal, } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { catchError, distinctUntilChanged, map, of, switchMap, tap } from 'rxjs'; +import { catchError, distinctUntilChanged, finalize, map, of, switchMap, tap } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CartService } from '../../../../core/services/cart/cart.service'; @@ -58,6 +58,7 @@ export class SearchPageComponent { protected readonly results = signal | null>(null); protected readonly error = signal(null); protected readonly creatingDirectPurchase = signal(false); + protected readonly savingProductIds = signal>(new Set()); protected readonly productLayout = computed( () => this.tenantService.tenant()?.search_product_layout ?? 'column_with_image', @@ -189,14 +190,31 @@ export class SearchPageComponent { } protected onAddToCart(event: ProductListCartEvent): void { - this.cartService.addItem(event.product.id, event.variant ?? null, event.quantity).subscribe({ - next: (response) => { - this.toastService.success(response.message || 'Producto agregado al carrito'); - }, - error: (error: HttpErrorResponse) => { - const message = error.error?.message || 'No se pudo agregar el producto al carrito.'; - this.toastService.danger(message); - }, + const productId = event.product.id; + if (this.savingProductIds().has(productId)) { + return; + } + + this.setProductSaving(productId, true); + this.cartService + .addItem(productId, event.variant ?? null, event.quantity) + .pipe(finalize(() => this.setProductSaving(productId, false))) + .subscribe({ + next: (response) => { + this.toastService.success(response.message || 'Producto agregado al carrito'); + }, + error: (error: HttpErrorResponse) => { + const message = error.error?.message || 'No se pudo agregar el producto al carrito.'; + this.toastService.danger(message); + }, + }); + } + + private setProductSaving(productId: number, saving: boolean): void { + this.savingProductIds.update((current) => { + const updated = new Set(current); + saving ? updated.add(productId) : updated.delete(productId); + return updated; }); } diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.html b/src/app/features/store/pages/store-home-page/store-home-page.component.html index 4f415ed..94425a4 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.html +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.html @@ -31,6 +31,7 @@ [loading]="isGroupLoading(group.id)" [loadImages]="!hasMainCarouselImages() || mainCarouselReady()" [unavailableVariantIds]="unavailableVariantIds()" + [savingProductIds]="savingProductIds()" (buy)="onBuyProduct($event)" (addToCart)="onAddToCart($event)" (pageChange)="onPageChange(group.id, $event)" diff --git a/src/app/features/store/pages/store-home-page/store-home-page.component.ts b/src/app/features/store/pages/store-home-page/store-home-page.component.ts index 051cc92..d0eb756 100644 --- a/src/app/features/store/pages/store-home-page/store-home-page.component.ts +++ b/src/app/features/store/pages/store-home-page/store-home-page.component.ts @@ -10,7 +10,7 @@ import { } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; import { ActivatedRoute, Router } from '@angular/router'; -import { Subscription } from 'rxjs'; +import { finalize, Subscription } from 'rxjs'; import { CartService } from '../../../../core/services/cart/cart.service'; import { AuthService } from '../../../../core/services/auth/auth.service'; @@ -64,6 +64,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { protected readonly mainCarouselReady = signal(false); protected readonly creatingDirectPurchase = signal(false); protected readonly unavailableVariantIds = signal>(new Set()); + protected readonly savingProductIds = signal>(new Set()); protected readonly mainCarouselImages = computed(() => this.tenant()?.extras?.carousel ?? []); protected readonly heroConfig = computed(() => this.tenant()?.extras?.heroConfig ?? null); protected readonly additionalInfo = computed( @@ -230,14 +231,31 @@ export class StoreHomePageComponent implements OnInit, OnDestroy { } protected onAddToCart(event: ProductListCartEvent): void { - this.cartService.addItem(event.product.id, event.variant ?? null, event.quantity).subscribe({ - next: (response) => { - this.toastService.success(response.message || 'Producto agregado al carrito'); - }, - error: (error: HttpErrorResponse) => { - const message = error.error?.message || 'No se pudo agregar el producto al carrito.'; - this.toastService.danger(message); - }, + const productId = event.product.id; + if (this.savingProductIds().has(productId)) { + return; + } + + this.setProductSaving(productId, true); + this.cartService + .addItem(productId, event.variant ?? null, event.quantity) + .pipe(finalize(() => this.setProductSaving(productId, false))) + .subscribe({ + next: (response) => { + this.toastService.success(response.message || 'Producto agregado al carrito'); + }, + error: (error: HttpErrorResponse) => { + const message = error.error?.message || 'No se pudo agregar el producto al carrito.'; + this.toastService.danger(message); + }, + }); + } + + private setProductSaving(productId: number, saving: boolean): void { + this.savingProductIds.update((current) => { + const updated = new Set(current); + saving ? updated.add(productId) : updated.delete(productId); + return updated; }); } diff --git a/src/app/shared/components/product-list/product-list.component.html b/src/app/shared/components/product-list/product-list.component.html index 5fd6425..2e1d4a8 100644 --- a/src/app/shared/components/product-list/product-list.component.html +++ b/src/app/shared/components/product-list/product-list.component.html @@ -13,6 +13,7 @@ [description]="item.descripcion ?? ''" [price]="price(item)" [variants]="item.variants ?? []" + [saving]="savingProductIds().has(item.id)" (buy)="emitRowBuy(item, $event)" (addToCart)="emitRowCart(item, $event)" /> @@ -23,6 +24,7 @@ [description]="item.descripcion ?? ''" [price]="price(item)" [variants]="item.variants ?? []" + [saving]="savingProductIds().has(item.id)" (buy)="emitColumnBuy(item, $event)" (addToCart)="emitColumnCart(item, $event)" /> 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 96de302..e8c3139 100644 --- a/src/app/shared/components/product-list/product-list.component.ts +++ b/src/app/shared/components/product-list/product-list.component.ts @@ -69,6 +69,7 @@ export class ProductListComponent { readonly loading = input(false); readonly loadImages = input(true); readonly unavailableVariantIds = input>(new Set()); + readonly savingProductIds = input>(new Set()); readonly buy = output(); readonly addToCart = output(); 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 71c593a..dde80d0 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 @@ -34,7 +34,9 @@ Comprar
- Agregar al carrito + + {{ saving() ? 'Guardando' : 'Agregar al carrito' }} +
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 8b659db..d9de24d 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 @@ -27,6 +27,7 @@ export class ProductRowCardComponent { readonly description = input(''); readonly price = input(0); readonly variants = input([]); + readonly saving = input(false); // Internal state models readonly quantity = model(1); @@ -51,6 +52,10 @@ export class ProductRowCardComponent { readonly formattedPrice = computed(() => this.formatCurrency(this.effectivePrice())); protected onAddToCart(): void { + if (this.saving()) { + return; + } + this.addToCart.emit({ quantity: this.quantity(), variant: this.selectedVariant(), 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 1327487..fad14ba 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 @@ -22,26 +22,38 @@
@for (row of rows(); track row.id) {
-
- @for (selector of row.selectors; track selector.key) { - - } - + @for (option of selector.options; track $index) { + + } + + } +
+ + @if (row.status === 'reserving') { + + Guardando... + }
[]) { + return { + variants, + selectors: attributes.map(({ key, label }, index) => ({ + key, + label, + options: index === 0 ? ['general'] : [], + enabled: index === 0, + })), + selected_values: {}, + resolved_variant: null, + valid: variants.length > 0, + available_variant_count: variants.length, + matching_variant_count: variants.length, + price_range: { minimum: '10000.00', maximum: '10000.00' }, + }; +} + +function cartWith(item: ReturnType | null = null) { + return { + id: 10, + tenant_codigo: 'demo', + status: 'active', + subtotal: item === null ? '0.00' : '10000.00', + items: + item === null + ? [] + : [ + { + id: 25, + cantidad: 1, + precio_unitario: '10000.00', + catalog_item_id: 7, + variant_id: item.id, + nombre: 'Entrada', + imagen: null, + variant: item, + }, + ], + }; +} + describe('ProductTicketSelectorComponent', () => { beforeAll(() => { try { @@ -23,234 +81,26 @@ describe('ProductTicketSelectorComponent', () => { afterEach(() => TestBed.resetTestingModule()); - it('checks partial selections and reserves the resolved variant in the cart', async () => { - const openConfirmDelete = vi.fn().mockReturnValue(of(true)); - const removeItem = vi.fn().mockReturnValue( - of({ - data: { - id: 10, - tenant_codigo: 'demo', - status: 'active', - subtotal: '0.00', - items: [], - }, - }), - ); - const resolvedVariant = { - id: 401, - precio: '10000.00', - stock_tecnico: 1, - values: { sector: { value: 'a', label: 'Sector A' }, seat: '1' }, - }; - const summary = { - valid: true, - available_variant_count: 1, - matching_variant_count: 1, - price_range: { minimum: '10000.00', maximum: '10000.00' }, - }; - const catalogService = { - withoutLoading: vi.fn(), - getVariantOptions: vi - .fn() - .mockReturnValueOnce( - of({ - ...summary, - selectors: [ - { - key: 'sector', - label: 'Sector', - options: [{ value: 'a', label: 'Sector A' }], - enabled: true, - }, - { key: 'seat', label: 'Seat', options: ['1'], enabled: false }, - ], - selected_values: {}, - resolved_variant: null, - }), - ) - .mockReturnValueOnce( - of({ - ...summary, - selectors: [ - { - key: 'sector', - label: 'Sector', - options: [{ value: 'a', label: 'Sector A' }], - enabled: true, - }, - { key: 'seat', label: 'Seat', options: ['1'], enabled: true }, - ], - selected_values: { sector: 'a' }, - resolved_variant: null, - }), - ) - .mockReturnValueOnce( - of({ - ...summary, - selectors: [], - selected_values: { sector: 'a', seat: '1' }, - resolved_variant: resolvedVariant, - }), - ), - }; - const cartService = { - cart: signal(null).asReadonly(), - withoutLoading: vi.fn(), - 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, - nombre: 'Entrada', - imagen: null, - variant: resolvedVariant, - }, - ], - }, - }), - ), - removeItem, - }; - catalogService.withoutLoading.mockReturnValue(catalogService); - cartService.withoutLoading.mockReturnValue(cartService); - await TestBed.configureTestingModule({ - imports: [ProductTicketSelectorComponent], - providers: [ - { provide: CatalogService, useValue: catalogService }, - { provide: CartService, useValue: cartService }, - { provide: ModalService, useValue: { openConfirmDelete } }, - ], - }).compileComponents(); - const fixture = TestBed.createComponent(ProductTicketSelectorComponent); - fixture.componentRef.setInput('productId', 7); - fixture.componentRef.setInput('title', 'Entrada'); - fixture.detectChanges(); - await fixture.whenStable(); - fixture.detectChanges(); - - expect(catalogService.getVariantOptions).toHaveBeenCalledTimes(1); - expect(catalogService.withoutLoading).toHaveBeenCalled(); - expect( - Array.from(fixture.nativeElement.querySelectorAll('select')).every( - (select) => !select.disabled, - ), - ).toBe(true); - - fixture.componentInstance['onSelectionChange'](1, 'sector', { - value: 'a', - label: 'Sector A', - }); - - expect(catalogService.getVariantOptions).toHaveBeenLastCalledWith(7, { - selected_values: { sector: 'a' }, - cart_item_id: null, - }); - expect(fixture.componentInstance['rows']()[0].status).toBe('selecting'); - - fixture.componentInstance['onSelectionChange'](1, 'seat', '1'); - - expect(cartService.addItem).toHaveBeenCalledWith(7, 401, 1); - expect(cartService.withoutLoading).toHaveBeenCalled(); - expect(fixture.componentInstance['rows']()[0]).toMatchObject({ - variantId: 401, - reservedVariantId: 401, - cartItemId: 25, - status: 'reserved', - }); - - fixture.detectChanges(); - expect(fixture.nativeElement.querySelector('.ticket-selector__row-status')).toBeNull(); - expect(fixture.nativeElement.querySelector('.ticket-selector__row-error')).toBeNull(); - - const deleteButton = fixture.nativeElement.querySelector( - 'app-icon-button button', - ) as HTMLButtonElement; - expect(deleteButton.classList.contains('icon-btn--bordered')).toBe(true); - deleteButton.click(); - - expect(openConfirmDelete).toHaveBeenCalledWith({ - title: 'Eliminar entrada', - content: - 'Se eliminará esta entrada de “Entrada”. Si ya estaba reservada, se liberará del carrito.', - confirmLabel: 'Sí, eliminar', - cancelLabel: 'Cancelar', - size: 'md', - }); - expect(removeItem).toHaveBeenCalledWith(25); - expect(fixture.componentInstance['rows']()).toHaveLength(0); - }); - - it('restores selections that are already reserved in the cart', 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, - nombre: 'Entrada', - imagen: null, - variant: { - id: 401, - precio: '10000.00', - stock_tecnico: 1, - values: { - sector: { value: 'a', label: 'Sector A' }, - seat: '1', - }, - }, - }, - ], - }); - const catalogService = { - withoutLoading: vi.fn(), - getVariantOptions: vi.fn().mockReturnValue( - of({ - valid: true, - available_variant_count: 1, - matching_variant_count: 1, - price_range: { minimum: '10000.00', maximum: '10000.00' }, - selectors: [ - { - key: 'sector', - label: 'Sector', - options: [ - { value: 'a', label: 'Sector A' }, - { value: 'b', label: 'Sector B' }, - ], - enabled: true, - }, - { key: 'seat', label: 'Seat', options: ['1', '2'], enabled: true }, - ], - selected_values: { sector: 'b', seat: '2' }, - resolved_variant: { - id: 401, - precio: '10000.00', - stock_tecnico: 1, - values: { sector: { value: 'b', label: 'Sector B' }, seat: '2' }, - }, - }), - ), - }; + async function createComponent({ + maps, + addItem = vi.fn(), + cartState = signal(cartWith()), + toastDanger = vi.fn(), + }: { + maps: ReturnType[]; + addItem?: ReturnType; + cartState?: ReturnType>>; + toastDanger?: ReturnType; + }) { + const getVariantOptions = vi.fn(); + maps.forEach((response) => getVariantOptions.mockReturnValueOnce(of(response))); + const catalogService = { withoutLoading: vi.fn(), getVariantOptions }; const cartService = { cart: cartState.asReadonly(), withoutLoading: vi.fn(), - addItem: vi.fn(), + addItem, updateItemVariant: vi.fn(), + removeItem: vi.fn().mockReturnValue(of({ data: cartWith() })), }; catalogService.withoutLoading.mockReturnValue(catalogService); cartService.withoutLoading.mockReturnValue(cartService); @@ -260,8 +110,14 @@ describe('ProductTicketSelectorComponent', () => { providers: [ { provide: CatalogService, useValue: catalogService }, { provide: CartService, useValue: cartService }, + { + provide: ModalService, + useValue: { openConfirmDelete: vi.fn().mockReturnValue(of(true)) }, + }, + { provide: ToastService, useValue: { danger: toastDanger } }, ], }).compileComponents(); + const fixture = TestBed.createComponent(ProductTicketSelectorComponent); fixture.componentRef.setInput('productId', 7); fixture.componentRef.setInput('title', 'Entrada'); @@ -269,162 +125,157 @@ describe('ProductTicketSelectorComponent', () => { await fixture.whenStable(); fixture.detectChanges(); - expect(catalogService.getVariantOptions).toHaveBeenCalledWith(7, { - selected_values: {}, - cart_item_id: 25, - }); - expect(fixture.componentInstance['rows']()[0]).toMatchObject({ - variantId: 401, - reservedVariantId: 401, - cartItemId: 25, - selectedValues: { sector: 'b', seat: '2' }, - status: 'reserved', - }); - expect(cartService.addItem).not.toHaveBeenCalled(); - expect(cartService.updateItemVariant).not.toHaveBeenCalled(); - const selects = Array.from(fixture.nativeElement.querySelectorAll('select')); - expect(selects.map((select) => select.selectedOptions[0]?.textContent?.trim())).toEqual([ - 'Sector B', - '2', - ]); - }); + return { fixture, getVariantOptions, cartService, toastDanger }; + } - 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, - nombre: 'Entrada', - imagen: null, - variant: { - id: 401, - precio: '10000.00', - stock_tecnico: 0, - values: { seat: '1' }, - }, - }, - ], + it('loads one map, recalculates the cascade locally and reserves only after the last select', async () => { + const selectedVariant = variant(401, 'general', 'A', '1', '1'); + const variants = [ + selectedVariant, + variant(402, 'general', 'A', '1', '2'), + variant(403, 'general', 'B', '2', '1'), + ]; + const addItem = vi.fn().mockReturnValue(of({ data: cartWith(selectedVariant) })); + const { fixture, getVariantOptions } = await createComponent({ + maps: [mapResponse(variants)], + addItem, }); - const selector = { - key: 'seat', - label: 'Asiento', + const component = fixture.componentInstance; + + expect(getVariantOptions).toHaveBeenCalledTimes(1); + expect(getVariantOptions).toHaveBeenCalledWith(7, { selected_values: {} }); + + component['onSelectionChange'](1, 'tipo', 'general'); + component['onSelectionChange'](1, 'sector', 'A'); + component['onSelectionChange'](1, 'fila', '1'); + + expect(getVariantOptions).toHaveBeenCalledTimes(1); + expect(addItem).not.toHaveBeenCalled(); + expect(component['rows']()[0].selectors[3]).toMatchObject({ + key: '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' }, - }, + }); + + component['onSelectionChange'](1, 'asiento', '1'); + + expect(getVariantOptions).toHaveBeenCalledTimes(1); + expect(addItem).toHaveBeenCalledWith(7, 401, 1); + expect(component['rows']()[0]).toMatchObject({ + variantId: 401, + reservedVariantId: 401, + status: 'reserved', + }); + }); + + it('shows a saving status while the selected seat is being reserved', async () => { + const selectedVariant = variant(401, 'general', 'A', '1', '1'); + const reservation = new Subject<{ data: ReturnType }>(); + const addItem = vi.fn().mockReturnValue(reservation); + const { fixture } = await createComponent({ + maps: [mapResponse([selectedVariant])], + addItem, + }); + const component = fixture.componentInstance; + + component['onSelectionChange'](1, 'tipo', 'general'); + component['onSelectionChange'](1, 'sector', 'A'); + component['onSelectionChange'](1, 'fila', '1'); + component['onSelectionChange'](1, 'asiento', '1'); + fixture.detectChanges(); + + const savingStatus = fixture.nativeElement.querySelector( + '.ticket-selector__row-status', + ) as HTMLElement | null; + expect(savingStatus?.textContent?.trim()).toBe('Guardando...'); + expect(savingStatus?.getAttribute('role')).toBe('status'); + + reservation.next({ data: cartWith(selectedVariant) }); + reservation.complete(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.ticket-selector__row-status')).toBeNull(); + }); + + it('clears only the seat after a stock conflict when the row still has alternatives', async () => { + const failed = variant(401, 'general', 'A', '1', '1'); + const alternative = variant(402, 'general', 'A', '1', '2'); + const message = 'El asiento ya no está disponible.'; + const addItem = vi.fn().mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 422, + error: { message }, }), - ) - .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, - }), ); + const { fixture, getVariantOptions, toastDanger } = await createComponent({ + maps: [mapResponse([failed, alternative]), mapResponse([alternative])], + addItem, + }); + const component = fixture.componentInstance; - fixture.componentInstance['onSelectionChange'](1, 'seat', '2'); - fixture.detectChanges(); + component['onSelectionChange'](1, 'tipo', 'general'); + component['onSelectionChange'](1, 'sector', 'A'); + component['onSelectionChange'](1, 'fila', '1'); + component['onSelectionChange'](1, 'asiento', '1'); - expect(cartService.updateItemVariant).toHaveBeenCalledTimes(1); - expect(toastService.danger).toHaveBeenLastCalledWith(unavailableMessage); + expect(getVariantOptions).toHaveBeenCalledTimes(2); + expect(component['rows']()[0]).toMatchObject({ + selectedValues: { tipo: 'general', sector: 'A', fila: '1' }, + variantId: null, + status: 'selecting', + error: message, + }); + expect(component['rows']()[0].selectors[3].options).toEqual(['2']); + expect(toastDanger).toHaveBeenCalledWith(message); + }); + + it('also clears the row when the refreshed map has no seats for that row', async () => { + const failed = variant(401, 'general', 'A', '1', '1'); + const otherRow = variant(402, 'general', 'A', '2', '1'); + const addItem = vi + .fn() + .mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 422, error: { message: 'Sin stock.' } })), + ); + const { fixture } = await createComponent({ + maps: [mapResponse([failed, otherRow]), mapResponse([otherRow])], + addItem, + }); + const component = fixture.componentInstance; + + component['onSelectionChange'](1, 'tipo', 'general'); + component['onSelectionChange'](1, 'sector', 'A'); + component['onSelectionChange'](1, 'fila', '1'); + component['onSelectionChange'](1, 'asiento', '1'); + + expect(component['rows']()[0].selectedValues).toEqual({ tipo: 'general', sector: 'A' }); + expect(component['rows']()[0].selectors[2]).toMatchObject({ + key: 'fila', + options: ['2'], + enabled: true, + }); + expect(component['rows']()[0].selectors[3].enabled).toBe(false); + }); + + it('restores a variant already reserved in the cart without another options request', async () => { + const reserved = variant(401, 'general', 'A', '1', '1'); + const cartState = signal(cartWith(reserved)); + const { fixture, getVariantOptions, cartService } = await createComponent({ + maps: [mapResponse([])], + cartState, + }); + + expect(getVariantOptions).toHaveBeenCalledTimes(1); + expect(cartService.updateItemVariant).not.toHaveBeenCalled(); expect(fixture.componentInstance['rows']()[0]).toMatchObject({ variantId: 401, reservedVariantId: 401, - selectedValues: { seat: '1' }, + cartItemId: 25, + selectedValues: { tipo: 'general', sector: 'A', fila: '1', asiento: '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 f3db64d..254af04 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 @@ -15,16 +15,17 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Subscription } from 'rxjs'; -import { CartService } from '../../../core/services/cart/cart.service'; import { CartItem } from '../../../core/services/cart/cart.interface'; -import { ModalService } from '../../../core/services/modal.service'; -import { ToastService } from '../../../core/services/toast.service'; +import { CartService } from '../../../core/services/cart/cart.service'; import { + CatalogFeaturedItemVariant, CatalogVariantOptionsResponse, CatalogVariantSelector, CatalogVariantValue, } from '../../../core/services/catalog/catalog.interface'; import { CatalogService } from '../../../core/services/catalog/catalog.service'; +import { ModalService } from '../../../core/services/modal.service'; +import { ToastService } from '../../../core/services/toast.service'; import { ButtonComponent } from '../button/button.component'; import { IconButtonComponent } from '../icon-button/icon-button.component'; @@ -36,6 +37,11 @@ type TicketSelectionStatus = | 'removing' | 'error'; +interface VariantAttribute { + key: string; + label: string; +} + interface TicketSelectionRow { id: number; variantId: number | null; @@ -44,7 +50,6 @@ interface TicketSelectionRow { selectedValues: Record; selectors: CatalogVariantSelector[]; reservedSelectedValues: Record; - reservedSelectors: CatalogVariantSelector[]; status: TicketSelectionStatus; error: string | null; } @@ -62,9 +67,12 @@ export class ProductTicketSelectorComponent { 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(); private readonly viewReady = signal(false); + private readonly mapReady = signal(false); + private readonly variants = signal([]); + private readonly attributes = signal([]); + private mapRequest: Subscription | null = null; readonly productId = input.required(); readonly title = input.required(); @@ -76,10 +84,17 @@ export class ProductTicketSelectorComponent { readonly buy = output(); protected readonly rows = signal([this.createRow(1)]); - protected readonly availableVariantCount = signal(0); private readonly remotePriceRange = signal<{ minimum: number; maximum: number } | null>(null); private nextRowId = 2; + protected readonly availableVariantCount = computed(() => { + const reservedIds = new Set( + this.rows().flatMap(({ reservedVariantId }) => + reservedVariantId === null ? [] : [reservedVariantId], + ), + ); + return this.variants().filter(({ id }) => !reservedIds.has(id)).length; + }); protected readonly hasSelection = computed( () => this.rows().length > 0 && @@ -100,7 +115,6 @@ export class ProductTicketSelectorComponent { const range = this.remotePriceRange(); const minimum = range?.minimum ?? this.price(); const maximum = range?.maximum ?? this.price(); - return { minimum: this.formatCurrency(minimum), maximum: this.formatCurrency(maximum), @@ -111,24 +125,22 @@ export class ProductTicketSelectorComponent { constructor() { effect(() => { const viewReady = this.viewReady(); + const mapReady = this.mapReady(); const cart = this.cartService.cart?.() ?? null; - if (!viewReady) return; untracked(() => { - if (cart === null) { - this.ensureEmptyRowLoaded(); + if (!mapReady) { + this.loadVariantMap(); return; } - - this.synchronizeWithCart(cart.items); + this.synchronizeWithCart(cart?.items ?? []); }); }); afterNextRender(() => this.viewReady.set(true)); - this.destroyRef.onDestroy(() => { - this.optionRequests.forEach((request) => request.unsubscribe()); + this.mapRequest?.unsubscribe(); this.reservationRequests.forEach((request) => request.unsubscribe()); }); } @@ -137,24 +149,20 @@ export class ProductTicketSelectorComponent { const variantIds = this.rows().flatMap((row) => row.reservedVariantId === null ? [] : [row.reservedVariantId], ); - - if (this.hasSelection() && variantIds.length > 0) { - this.buy.emit(variantIds); - } + if (this.hasSelection() && variantIds.length > 0) this.buy.emit(variantIds); } protected addRow(): void { if (!this.canAddRow()) return; - const row = this.createRow(this.nextRowId++); + row.status = 'selecting'; + row.selectors = this.buildSelectors(row.selectedValues, row); this.rows.update((rows) => [...rows, row]); - this.loadOptions(row.id, {}); } protected removeRow(rowId: number): void { const row = this.findRow(rowId); if (!row || this.isBusy(row)) return; - this.modalService .openConfirmDelete({ title: 'Eliminar entrada', @@ -170,32 +178,24 @@ export class ProductTicketSelectorComponent { } private confirmRemoveRow(row: TicketSelectionRow): void { - const rowId = row.id; - - this.optionRequests.get(rowId)?.unsubscribe(); - if (row.cartItemId === null) { - this.deleteRow(rowId); + this.deleteRow(row.id); return; } - - this.patchRow(rowId, { status: 'removing', error: null }); + this.patchRow(row.id, { status: 'removing', error: null }); const request = this.cartService .withoutLoading() .removeItem(row.cartItemId) .subscribe({ - next: () => { - this.availableVariantCount.update((count) => count + 1); - this.deleteRow(rowId); - }, + next: () => this.deleteRow(row.id), error: (error: HttpErrorResponse) => { - this.patchRow(rowId, { + this.patchRow(row.id, { status: 'reserved', error: this.errorMessage(error, 'No se pudo liberar la entrada.'), }); }, }); - this.reservationRequests.set(rowId, request); + this.reservationRequests.set(row.id, request); } protected onSelectionChange( @@ -205,18 +205,25 @@ export class ProductTicketSelectorComponent { ): void { const row = this.findRow(rowId); if (!row || this.isBusy(row)) return; + const selectorIndex = this.attributes().findIndex(({ key }) => key === selectorKey); + if (selectorIndex < 0) return; const selectedValues = { ...row.selectedValues }; - if (value === null) delete selectedValues[selectorKey]; else selectedValues[selectorKey] = this.normalizeValue(value); + this.attributes() + .slice(selectorIndex + 1) + .forEach(({ key }) => delete selectedValues[key]); - const preservesReservedSelection = - row.reservedVariantId !== null && - row.selectors.length > 0 && - row.selectors.every(({ key }) => key in selectedValues); - - this.loadOptions(rowId, selectedValues, preservesReservedSelection); + const variant = this.resolveVariant(row, selectedValues); + this.patchRow(rowId, { + variantId: variant?.id ?? null, + selectedValues, + selectors: this.buildSelectors(selectedValues, row), + status: 'selecting', + error: null, + }); + if (variant !== null) this.reserveRow(rowId, variant.id, selectedValues); } protected optionLabel(value: CatalogVariantValue): string { @@ -234,7 +241,6 @@ export class ProductTicketSelectorComponent { protected selectedOptionKey(row: TicketSelectionRow, key: string): string { const selectedOption = this.selectedOption(row, key); - return selectedOption === null ? '' : this.valueKey(selectedOption); } @@ -245,7 +251,6 @@ export class ProductTicketSelectorComponent { ): void { const selectedOption = selector.options.find((option) => this.valueKey(option) === selectedKey) ?? null; - this.onSelectionChange(rowId, selector.key, selectedOption); } @@ -253,111 +258,130 @@ export class ProductTicketSelectorComponent { return this.disabled() || this.isBusy(row); } - private loadOptions( - rowId: number, - selectedValues: Record, - preserveReservedSelection = false, + private loadVariantMap( + onLoaded?: () => void, + onError?: (error: HttpErrorResponse) => void, ): void { - const row = this.findRow(rowId); - if (!row) return; - - this.optionRequests.get(rowId)?.unsubscribe(); - this.patchRow(rowId, { - selectedValues: preserveReservedSelection ? row.reservedSelectedValues : selectedValues, - status: 'checking', - error: null, - }); - + if (this.mapRequest !== null && !onLoaded) return; + this.mapRequest?.unsubscribe(); const request = this.catalogService .withoutLoading() - .getVariantOptions(this.productId(), { - selected_values: selectedValues, - cart_item_id: row.cartItemId, - }) + .getVariantOptions(this.productId(), { selected_values: {} }) .subscribe({ next: (response) => { - 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: {}, - selectors: response.selectors, - status: response.available_variant_count === 0 ? 'selecting' : 'error', - error: - response.available_variant_count === 0 - ? null - : 'La combinación seleccionada ya no está disponible.', - }); - return; - } - - this.patchRow(rowId, { - variantId: response.resolved_variant?.id ?? null, - 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, { - reservedSelectedValues: response.selected_values, - reservedSelectors: response.selectors, - status: 'reserved', - }); - } else { - this.reserveRow( - rowId, - response.resolved_variant.id, - response.selected_values, - response.selectors, - ); - } - } + this.applyVariantMap(response); + this.mapReady.set(true); + this.mapRequest = null; + onLoaded?.(); }, error: (error: HttpErrorResponse) => { - const message = this.errorMessage(error, 'No se pudo consultar la disponibilidad.'); - - if (row.reservedVariantId !== null) { - this.restoreReservedRowAfterError(rowId, row, message); + this.mapRequest = null; + if (onError) { + onError(error); return; } - - this.patchRow(rowId, { status: 'error', error: message }); + const message = this.errorMessage(error, 'No se pudo consultar la disponibilidad.'); + this.rows.update((rows) => + rows.map((row) => ({ ...row, status: 'error', error: message })), + ); this.toastService.danger(message); }, }); - this.optionRequests.set(rowId, request); + this.mapRequest = request; + } + + private applyVariantMap(response: CatalogVariantOptionsResponse): void { + this.variants.set(response.variants); + this.attributes.set(response.selectors.map(({ key, label }) => ({ key, label }))); + this.remotePriceRange.set({ + minimum: Number(response.price_range.minimum), + maximum: Number(response.price_range.maximum), + }); + } + + private buildSelectors( + selectedValues: Record, + row: TicketSelectionRow, + variants = this.availableVariantsFor(row), + ): CatalogVariantSelector[] { + return this.attributes().map(({ key, label }, index, attributes) => { + const previousKeys = attributes.slice(0, index).map((attribute) => attribute.key); + const compatibleVariants = variants.filter((variant) => + previousKeys.every( + (previousKey) => + previousKey in selectedValues && + this.valueKey(variant.values[previousKey]) === + this.valueKey(selectedValues[previousKey]), + ), + ); + return { + key, + label, + options: this.uniqueValues(compatibleVariants.map((variant) => variant.values[key])), + enabled: index === 0 || previousKeys.every((previousKey) => previousKey in selectedValues), + }; + }); + } + + private resolveVariant( + row: TicketSelectionRow, + selectedValues: Record, + ): CatalogFeaturedItemVariant | null { + const attributes = this.attributes(); + if (attributes.length === 0 || attributes.some(({ key }) => !(key in selectedValues))) + return null; + const matches = this.availableVariantsFor(row).filter((variant) => + attributes.every( + ({ key }) => this.valueKey(variant.values[key]) === this.valueKey(selectedValues[key]), + ), + ); + return matches.length === 1 ? matches[0] : null; + } + + private availableVariantsFor(row: TicketSelectionRow): CatalogFeaturedItemVariant[] { + const reservedByOtherRows = new Set( + this.rows().flatMap((candidate) => + candidate.id !== row.id && candidate.reservedVariantId !== null + ? [candidate.reservedVariantId] + : [], + ), + ); + const variants = [...this.variants()]; + const reservedVariant = this.reservedVariantData(row); + if (reservedVariant !== null && !variants.some(({ id }) => id === reservedVariant.id)) { + variants.push(reservedVariant); + } + return variants.filter(({ id }) => !reservedByOtherRows.has(id)); + } + + private reservedVariantData(row: TicketSelectionRow): CatalogFeaturedItemVariant | null { + if (row.cartItemId === null || row.reservedVariantId === null) return null; + const item = this.cartService.cart?.()?.items.find(({ id }) => id === row.cartItemId); + if (!item?.variant) return null; + return { + id: item.variant.id, + precio: item.variant.precio, + stock_tecnico: item.variant.stock_tecnico, + values: item.variant.values, + }; + } + + private uniqueValues(values: Array): CatalogVariantValue[] { + const unique = new Map(); + values.forEach((value) => { + if (value !== undefined) unique.set(this.valueKey(value), value); + }); + return [...unique.values()]; } 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, { - selectedValues: row.reservedVariantId === null ? selectedValues : row.reservedSelectedValues, - selectors: row.reservedVariantId === null ? selectors : row.reservedSelectors, - status: 'reserving', - error: null, - }); + this.patchRow(rowId, { status: 'reserving', error: null }); const cartService = this.cartService.withoutLoading(); const operation = row.cartItemId === null @@ -372,7 +396,6 @@ export class ProductTicketSelectorComponent { 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, @@ -381,62 +404,104 @@ export class ProductTicketSelectorComponent { }); return; } - + const currentRow = this.findRow(rowId) ?? row; this.patchRow(rowId, { variantId, reservedVariantId: variantId, selectedValues, - selectors, + selectors: this.buildSelectors(selectedValues, currentRow), reservedSelectedValues: selectedValues, - reservedSelectors: selectors, cartItemId: cartItem.id, status: 'reserved', error: null, }); - if (row.cartItemId === null) { - this.availableVariantCount.update((count) => Math.max(0, count - 1)); - } }, error: (error: HttpErrorResponse) => { const message = this.errorMessage(error, 'La entrada ya no está disponible.'); - - if (row.reservedVariantId !== null) { - this.restoreReservedRowAfterError(rowId, row, message); + if (error.status === 422) { + this.recoverFromUnavailableVariant(rowId, variantId, message); return; } - - this.patchRow(rowId, { variantId: null, status: 'error', error: message }); - this.toastService.danger(message); + this.restoreReservedRowAfterError(rowId, row, message); }, }); this.reservationRequests.set(rowId, request); } + private recoverFromUnavailableVariant( + rowId: number, + failedVariantId: number, + message: string, + ): void { + this.patchRow(rowId, { status: 'checking', error: null }); + this.loadVariantMap( + () => this.backtrackSelection(rowId, failedVariantId, message), + () => { + const row = this.findRow(rowId); + if (row) this.restoreReservedRowAfterError(rowId, row, message); + }, + ); + } + + private backtrackSelection(rowId: number, failedVariantId: number, message: string): void { + const row = this.findRow(rowId); + if (!row) return; + const variants = this.availableVariantsFor(row).filter(({ id }) => id !== failedVariantId); + const selectedValues = { ...row.selectedValues }; + const attributes = this.attributes(); + + for (let index = attributes.length - 1; index >= 0; index--) { + delete selectedValues[attributes[index].key]; + const prefixKeys = attributes.slice(0, index).map(({ key }) => key); + const hasCompatibleVariant = variants.some((variant) => + prefixKeys.every( + (key) => + key in selectedValues && + this.valueKey(variant.values[key]) === this.valueKey(selectedValues[key]), + ), + ); + if (!hasCompatibleVariant) continue; + this.patchRow(rowId, { + variantId: null, + selectedValues: { ...selectedValues }, + selectors: this.buildSelectors(selectedValues, row, variants), + status: 'selecting', + error: message, + }); + this.toastService.danger(message); + return; + } + + this.patchRow(rowId, { + variantId: null, + selectedValues: {}, + selectors: this.buildSelectors({}, row, variants), + status: 'error', + error: message, + }); + this.toastService.danger(message); + } + 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, - }); + if (row.reservedVariantId === null) { + this.patchRow(rowId, { variantId: null, status: 'error', error: message }); + } else { + const selectedValues = row.reservedSelectedValues; + this.patchRow(rowId, { + variantId: row.reservedVariantId, + selectedValues, + selectors: this.buildSelectors(selectedValues, row), + 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)), - ); - this.remotePriceRange.set({ - minimum: Number(response.price_range.minimum), - maximum: Number(response.price_range.maximum), - }); - } - private createRow(id: number): TicketSelectionRow { return { id, @@ -446,25 +511,25 @@ export class ProductTicketSelectorComponent { selectedValues: {}, selectors: [], reservedSelectedValues: {}, - reservedSelectors: [], status: 'checking', error: null, }; } private createCartRow(item: CartItem, id = this.nextRowId++): TicketSelectionRow | null { - if (item.variant_id === null) return null; - + if (item.variant_id === null || item.variant === null) return null; + const selectedValues = Object.fromEntries( + Object.entries(item.variant.values).map(([key, value]) => [key, this.normalizeValue(value)]), + ); return { id, variantId: item.variant_id, reservedVariantId: item.variant_id, cartItemId: item.id, - selectedValues: {}, + selectedValues, selectors: [], - reservedSelectedValues: {}, - reservedSelectors: [], - status: 'checking', + reservedSelectedValues: selectedValues, + status: 'reserved', error: null, }; } @@ -474,61 +539,42 @@ export class ProductTicketSelectorComponent { (item) => item.catalog_item_id === this.productId() && item.variant_id !== null, ); const currentRows = this.rows(); - if (cartItems.length === 0) { - if (currentRows.some((row) => row.cartItemId !== null)) { - this.replaceRows([this.createRow(this.nextRowId++)]); - } - this.ensureEmptyRowLoaded(); + const localRows = currentRows.filter((row) => row.cartItemId === null); + const rows = localRows.length > 0 ? localRows : [this.createRow(this.nextRowId++)]; + this.replaceRows(this.withRefreshedSelectors(rows)); return; } - const rowsToLoad: TicketSelectionRow[] = []; const synchronizedRows = cartItems.flatMap((item) => { const existingRow = currentRows.find((row) => row.cartItemId === item.id); + if (existingRow?.reservedVariantId === item.variant_id) return [existingRow]; const cartRow = this.createCartRow(item, existingRow?.id); - - if (!cartRow) return []; - - if (existingRow && existingRow.reservedVariantId === cartRow.reservedVariantId) { - return [existingRow]; - } - - rowsToLoad.push(cartRow); - return [cartRow]; + return cartRow === null ? [] : [cartRow]; }); const localRows = currentRows.filter( (row) => row.cartItemId === null && Object.keys(row.selectedValues).length > 0, ); - - this.replaceRows([...synchronizedRows, ...localRows]); - rowsToLoad.forEach((row) => this.loadOptions(row.id, row.selectedValues)); + this.replaceRows(this.withRefreshedSelectors([...synchronizedRows, ...localRows])); } - private ensureEmptyRowLoaded(): void { - const currentRows = this.rows(); - - if (currentRows.length === 0) { - const row = this.createRow(this.nextRowId++); - this.rows.set([row]); - this.loadOptions(row.id, {}); - return; - } - - if (currentRows.length === 1 && !this.optionRequests.has(currentRows[0].id)) { - this.loadOptions(currentRows[0].id, currentRows[0].selectedValues); - } + private withRefreshedSelectors(rows: TicketSelectionRow[]): TicketSelectionRow[] { + const previousRows = this.rows(); + this.rows.set(rows); + const refreshed = rows.map((row) => ({ + ...row, + status: row.status === 'checking' ? ('selecting' as const) : row.status, + selectors: this.buildSelectors(row.selectedValues, row), + })); + this.rows.set(previousRows); + return refreshed; } private replaceRows(rows: TicketSelectionRow[]): void { const retainedIds = new Set(rows.map(({ id }) => id)); - this.rows().forEach((row) => { if (retainedIds.has(row.id)) return; - - this.optionRequests.get(row.id)?.unsubscribe(); this.reservationRequests.get(row.id)?.unsubscribe(); - this.optionRequests.delete(row.id); this.reservationRequests.delete(row.id); }); this.rows.set(rows); @@ -544,7 +590,6 @@ export class ProductTicketSelectorComponent { private deleteRow(rowId: number): void { this.rows.update((rows) => rows.filter((row) => row.id !== rowId)); - this.optionRequests.delete(rowId); this.reservationRequests.delete(rowId); } 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 970ff1c..4d53809 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 @@ -36,10 +36,10 @@ - Agregar al carrito + {{ saving() ? 'Guardando' : 'Agregar al carrito' }}
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 88d5ae7..f1804fd 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 @@ -24,6 +24,7 @@ export class ProductVerticalWithCartCardComponent { readonly description = input(''); readonly price = input(0); readonly variants = input([]); + readonly saving = input(false); readonly quantity = model(1); readonly selectedVariant = model(null); @@ -46,6 +47,10 @@ export class ProductVerticalWithCartCardComponent { protected readonly hasVariants = computed(() => this.variants().length > 0); protected onAddToCart(): void { + if (this.saving()) { + return; + } + this.addToCart.emit({ quantity: this.quantity(), variant: this.selectedVariant() }); }