feat(ticket-selector): implement zoomable image feature for seating map

This commit is contained in:
ncoronel 2026-09-02 14:26:12 -03:00
parent f0c1e74c1b
commit ebae063a0f
4 changed files with 64 additions and 6 deletions

View File

@ -107,6 +107,17 @@
</div>
@if (imageUrl(); as image) {
<img class="ticket-selector__map" [src]="image" [alt]="'Plano de ubicaciones de ' + title()" />
<button
type="button"
class="ticket-selector__map-button"
aria-label="Ampliar plano de ubicaciones"
(click)="openImage(image)"
>
<img
class="ticket-selector__map"
[src]="image"
[alt]="'Plano de ubicaciones de ' + title()"
/>
</button>
}
</article>

View File

@ -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;
}
}

View File

@ -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');

View File

@ -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++);