diff --git a/angular.json b/angular.json index 1b92efa..f75e020 100644 --- a/angular.json +++ b/angular.json @@ -34,7 +34,12 @@ "server": "src/main.server.ts", "outputMode": "server", "security": { - "allowedHosts": ["localhost", "127.0.0.1"] + "allowedHosts": [ + "localhost", + "localhost:4300", + "127.0.0.1", + "127.0.0.1:4300" + ] }, "ssr": { "entry": "src/server.ts" 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 7929bd6..a807a28 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 @@ -17,7 +17,31 @@
- Seleccioná Entrada/s: +
+ Seleccioná Entrada/s: + @if (validationStatus(); as status) { + + @switch (status) { + @case ('validating') { + + Validando + } + @case ('validated') { + + Validado + } + @case ('error') { + + Error + } + } + + } +
@for (row of rows(); track row.id) { @@ -45,12 +69,6 @@ [disabled]="rowDisabled(row)" (clicked)="removeRow(row.id)" /> - @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 34f210f..caa11c6 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 @@ -44,14 +44,49 @@ padding-top: 1rem; } - &__label { - display: block; + &__label-row { + display: flex; + align-items: center; + gap: 0.65rem; + min-height: 1.5rem; margin-bottom: 0.5rem; + } + + &__label { color: #555; font-size: 15px; font-weight: 700; } + &__validation { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 13px; + font-weight: 600; + + &--validating { + color: #777; + } + + &--validated { + color: var(--success-color, #198754); + } + + &--error { + color: var(--danger-color, #dc3545); + } + } + + &__validation-spinner { + width: 0.8rem; + height: 0.8rem; + border: 2px solid currentcolor; + border-right-color: transparent; + border-radius: 50%; + animation: ticket-selector-spin 0.7s linear infinite; + } + &__rows { display: grid; gap: 0.5rem; @@ -64,20 +99,6 @@ 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; display: grid; @@ -129,6 +150,18 @@ } } +@keyframes ticket-selector-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .ticket-selector__validation-spinner { + animation-duration: 1.4s; + } +} + @media (max-width: 767.98px) { .ticket-selector { &__header { 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 40a2997..f382f5b 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,4 +1,5 @@ import '@angular/compiler'; +import { signal } from '@angular/core'; import { TestBed, getTestBed } from '@angular/core/testing'; import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { of } from 'rxjs'; @@ -33,6 +34,7 @@ describe('ProductTicketSelectorComponent', () => { price_range: { minimum: '10000.00', maximum: '10000.00' }, }; const catalogService = { + withoutLoading: vi.fn(), getVariantOptions: vi .fn() .mockReturnValueOnce( @@ -77,6 +79,8 @@ describe('ProductTicketSelectorComponent', () => { ), }; const cartService = { + cart: signal(null).asReadonly(), + withoutLoading: vi.fn(), addItem: vi.fn().mockReturnValue( of({ data: { @@ -98,6 +102,8 @@ describe('ProductTicketSelectorComponent', () => { }), ), }; + catalogService.withoutLoading.mockReturnValue(catalogService); + cartService.withoutLoading.mockReturnValue(cartService); await TestBed.configureTestingModule({ imports: [ProductTicketSelectorComponent], providers: [ @@ -109,6 +115,10 @@ describe('ProductTicketSelectorComponent', () => { fixture.componentRef.setInput('productId', 7); fixture.componentRef.setInput('title', 'Entrada'); fixture.detectChanges(); + await fixture.whenStable(); + + expect(catalogService.getVariantOptions).toHaveBeenCalledTimes(1); + expect(catalogService.withoutLoading).toHaveBeenCalled(); fixture.componentInstance['onSelectionChange'](1, 'sector', { value: 'a', @@ -124,11 +134,123 @@ describe('ProductTicketSelectorComponent', () => { 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(); + const validation = fixture.nativeElement.querySelector('.ticket-selector__validation'); + expect(validation.textContent).toContain('Validado'); + expect(validation.querySelector('.fa-check')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.ticket-selector__row-status')).toBeNull(); + expect(fixture.nativeElement.querySelector('.ticket-selector__row-error')).toBeNull(); + }); + + 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, + product: { + nombre: 'Entrada', + imagen: null, + variants: [ + { + 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' }], + enabled: true, + }, + { key: 'seat', label: 'Seat', options: ['1'], enabled: true }, + ], + selected_values: { sector: 'a', seat: '1' }, + resolved_variant: { + id: 401, + precio: '10000.00', + stock_tecnico: 1, + values: { sector: { value: 'a', label: 'Sector A' }, seat: '1' }, + }, + }), + ), + }; + const cartService = { + cart: cartState.asReadonly(), + withoutLoading: vi.fn(), + addItem: vi.fn(), + updateItemVariant: vi.fn(), + }; + catalogService.withoutLoading.mockReturnValue(catalogService); + cartService.withoutLoading.mockReturnValue(cartService); + + 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.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(catalogService.getVariantOptions).toHaveBeenCalledWith(7, { + selected_values: { sector: 'a', seat: '1' }, + cart_item_id: 25, + }); + expect(fixture.componentInstance['rows']()[0]).toMatchObject({ + variantId: 401, + reservedVariantId: 401, + cartItemId: 25, + selectedValues: { sector: 'a', seat: '1' }, + 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 A', + '1', + ]); + expect( + fixture.nativeElement.querySelector('.ticket-selector__validation').textContent, + ).toContain('Validado'); }); }); 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 7583910..415772a 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 @@ -1,5 +1,6 @@ import { HttpErrorResponse } from '@angular/common/http'; import { + afterNextRender, ChangeDetectionStrategy, Component, computed, @@ -9,11 +10,13 @@ import { input, output, signal, + untracked, } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Subscription } from 'rxjs'; import { CartService } from '../../../core/services/cart/cart.service'; +import { CartItem, CartItemVariantValue } from '../../../core/services/cart/cart.interface'; import { CatalogVariantOptionsResponse, CatalogVariantSelector, @@ -31,6 +34,8 @@ type TicketSelectionStatus = | 'removing' | 'error'; +type TicketValidationStatus = 'validating' | 'validated' | 'error'; + interface TicketSelectionRow { id: number; variantId: number | null; @@ -55,6 +60,7 @@ export class ProductTicketSelectorComponent { private readonly destroyRef = inject(DestroyRef); private readonly optionRequests = new Map(); private readonly reservationRequests = new Map(); + private readonly viewReady = signal(false); readonly productId = input.required(); readonly title = input.required(); @@ -80,6 +86,15 @@ export class ProductTicketSelectorComponent { row.variantId === row.reservedVariantId, ), ); + protected readonly validationStatus = computed(() => { + const rows = this.rows(); + + if (rows.some((row) => this.isBusy(row))) return 'validating'; + if (rows.some((row) => row.status === 'error' || row.error !== null)) return 'error'; + if (rows.length > 0 && rows.every((row) => row.status === 'reserved')) return 'validated'; + + return null; + }); protected readonly canAddRow = computed( () => this.availableVariantCount() > 0 && @@ -100,10 +115,23 @@ export class ProductTicketSelectorComponent { constructor() { effect(() => { - this.productId(); - this.loadOptions(1, {}); + const viewReady = this.viewReady(); + const cart = this.cartService.cart?.() ?? null; + + if (!viewReady) return; + + untracked(() => { + if (cart === null) { + this.ensureEmptyRowLoaded(); + return; + } + + this.synchronizeWithCart(cart.items); + }); }); + afterNextRender(() => this.viewReady.set(true)); + this.destroyRef.onDestroy(() => { this.optionRequests.forEach((request) => request.unsubscribe()); this.reservationRequests.forEach((request) => request.unsubscribe()); @@ -140,18 +168,21 @@ export class ProductTicketSelectorComponent { } this.patchRow(rowId, { status: 'removing', error: null }); - const request = this.cartService.removeItem(row.cartItemId).subscribe({ - next: () => { - this.availableVariantCount.update((count) => count + 1); - this.deleteRow(rowId); - }, - error: (error: HttpErrorResponse) => { - this.patchRow(rowId, { - status: 'reserved', - error: this.errorMessage(error, 'No se pudo liberar la entrada.'), - }); - }, - }); + const request = this.cartService + .withoutLoading() + .removeItem(row.cartItemId) + .subscribe({ + next: () => { + this.availableVariantCount.update((count) => count + 1); + 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); } @@ -175,12 +206,12 @@ export class ProductTicketSelectorComponent { this.loadOptions(rowId, selectedValues); } - protected compareValues( + protected readonly compareValues = ( left: CatalogVariantValue | null, right: CatalogVariantValue | null, - ): boolean { + ): boolean => { return left !== null && right !== null && this.valueKey(left) === this.valueKey(right); - } + }; protected optionLabel(value: CatalogVariantValue): string { const values = Array.isArray(value) ? value : [value]; @@ -195,15 +226,6 @@ export class ProductTicketSelectorComponent { 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 loadOptions(rowId: number, selectedValues: Record): void { const row = this.findRow(rowId); if (!row) return; @@ -212,6 +234,7 @@ export class ProductTicketSelectorComponent { this.patchRow(rowId, { selectedValues, status: 'checking', error: null }); const request = this.catalogService + .withoutLoading() .getVariantOptions(this.productId(), { selected_values: selectedValues, cart_item_id: row.cartItemId, @@ -243,7 +266,11 @@ export class ProductTicketSelectorComponent { }); if (response.resolved_variant !== null) { - this.reserveRow(rowId, response.resolved_variant.id); + if (row.cartItemId !== null && row.reservedVariantId === response.resolved_variant.id) { + this.patchRow(rowId, { status: 'reserved' }); + } else { + this.reserveRow(rowId, response.resolved_variant.id); + } } }, error: (error: HttpErrorResponse) => { @@ -261,10 +288,11 @@ export class ProductTicketSelectorComponent { if (!row || (row.status === 'reserved' && row.reservedVariantId === variantId)) return; this.patchRow(rowId, { status: 'reserving', error: null }); + const cartService = this.cartService.withoutLoading(); const operation = row.cartItemId === null - ? this.cartService.addItem(this.productId(), variantId, 1) - : this.cartService.updateItemVariant(row.cartItemId, 1, variantId); + ? cartService.addItem(this.productId(), variantId, 1) + : cartService.updateItemVariant(row.cartItemId, 1, variantId); const request = operation.subscribe({ next: (response) => { const cartItem = @@ -329,6 +357,108 @@ export class ProductTicketSelectorComponent { }; } + private createCartRow(item: CartItem, id = this.nextRowId++): TicketSelectionRow | null { + if (item.variant_id === null) return null; + + const variant = item.product?.variants?.find(({ id }) => id === item.variant_id); + if (!variant) return null; + + return { + id, + variantId: item.variant_id, + reservedVariantId: item.variant_id, + cartItemId: item.id, + selectedValues: this.normalizeCartValues(variant.values), + selectors: [], + status: 'checking', + error: null, + }; + } + + private synchronizeWithCart(items: CartItem[]): void { + const cartItems = items.filter( + (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(); + return; + } + + const rowsToLoad: TicketSelectionRow[] = []; + const synchronizedRows = cartItems.flatMap((item) => { + const existingRow = currentRows.find((row) => row.cartItemId === item.id); + const cartRow = this.createCartRow(item, existingRow?.id); + + if (!cartRow) return []; + + if ( + existingRow && + existingRow.reservedVariantId === cartRow.reservedVariantId && + this.sameSelectedValues(existingRow.selectedValues, cartRow.selectedValues) + ) { + return [existingRow]; + } + + rowsToLoad.push(cartRow); + return [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)); + } + + 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 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); + } + + private normalizeCartValues( + values: Record, + ): Record { + return Object.fromEntries( + Object.entries(values).map(([key, value]) => [key, this.normalizeValue(value)]), + ); + } + + private sameSelectedValues( + left: Record, + right: Record, + ): boolean { + return JSON.stringify(left) === JSON.stringify(right); + } + private findRow(rowId: number): TicketSelectionRow | undefined { return this.rows().find((row) => row.id === rowId); }