From ebae063a0ff438f977ae9b25625b6ca30b3dc589 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 2 Sep 2026 14:26:12 -0300 Subject: [PATCH] feat(ticket-selector): implement zoomable image feature for seating map --- .../product-ticket-selector.component.html | 13 ++++++++- .../product-ticket-selector.component.scss | 22 ++++++++++++--- .../product-ticket-selector.component.spec.ts | 27 +++++++++++++++++-- .../product-ticket-selector.component.ts | 8 ++++++ 4 files changed, 64 insertions(+), 6 deletions(-) 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 fc92f95..01df682 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 @@ -107,6 +107,17 @@ @if (imageUrl(); as image) { - + } 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 4b2496a..58cb81b 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 @@ -153,11 +153,27 @@ margin-top: 0.75rem; } - &__map { + &__map-button { display: block; width: min(100%, 720px); - max-height: 680px; margin: 3rem auto 0; + padding: 0; + overflow: hidden; + border: 0; + border-radius: 0.4rem; + background: transparent; + cursor: zoom-in; + + &:focus-visible { + outline: 3px solid var(--tenant-primary); + outline-offset: 0.25rem; + } + } + + &__map { + display: block; + width: 100%; + max-height: 680px; object-fit: contain; } } @@ -188,7 +204,7 @@ display: none; } - &__map { + &__map-button { margin-top: 2rem; } } 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 311d51a..9b0aebe 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 @@ -104,6 +104,10 @@ describe('ProductTicketSelectorComponent', () => { }; catalogService.withoutLoading.mockReturnValue(catalogService); cartService.withoutLoading.mockReturnValue(cartService); + const modalService = { + openConfirmDelete: vi.fn().mockReturnValue(of(true)), + openImage: vi.fn(), + }; await TestBed.configureTestingModule({ imports: [ProductTicketSelectorComponent], @@ -112,7 +116,7 @@ describe('ProductTicketSelectorComponent', () => { { provide: CartService, useValue: cartService }, { provide: ModalService, - useValue: { openConfirmDelete: vi.fn().mockReturnValue(of(true)) }, + useValue: modalService, }, { provide: ToastService, useValue: { danger: toastDanger } }, ], @@ -125,7 +129,7 @@ describe('ProductTicketSelectorComponent', () => { await fixture.whenStable(); fixture.detectChanges(); - return { fixture, getVariantOptions, cartService, toastDanger }; + return { fixture, getVariantOptions, cartService, toastDanger, modalService }; } it('loads one map, recalculates the cascade locally and reserves only after the last select', async () => { @@ -226,6 +230,25 @@ describe('ProductTicketSelectorComponent', () => { ]); }); + it('opens the zoomable image modal when the seating map is clicked', async () => { + const { fixture, modalService } = await createComponent({ + maps: [mapResponse([variant(401, 'general', 'A', '1', '1')])], + }); + fixture.componentRef.setInput('imageUrl', '/images/plano.png'); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector( + 'button[aria-label="Ampliar plano de ubicaciones"]', + ) as HTMLButtonElement; + button.click(); + + expect(modalService.openImage).toHaveBeenCalledWith({ + title: 'Entrada', + src: '/images/plano.png', + alt: 'Plano de ubicaciones de Entrada', + }); + }); + 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'); 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 f420a03..7d96ec7 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 @@ -154,6 +154,14 @@ export class ProductTicketSelectorComponent { if (this.hasSelection() && variantIds.length > 0) this.buy.emit(variantIds); } + protected openImage(src: string): void { + this.modalService.openImage({ + title: this.title(), + src, + alt: `Plano de ubicaciones de ${this.title()}`, + }); + } + protected addRow(): void { if (!this.canAddRow()) return; const row = this.createRow(this.nextRowId++);