diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket/ticket.component.html b/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket/ticket.component.html index 37fd036..b7a4373 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket/ticket.component.html +++ b/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket/ticket.component.html @@ -41,9 +41,15 @@ - + } diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket/ticket.component.ts b/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket/ticket.component.ts index 6e07cd4..3687897 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket/ticket.component.ts +++ b/src/app/features/store/pages/account-page/pages/tickets-page/components/ticket/ticket.component.ts @@ -16,6 +16,7 @@ export class TicketComponent { readonly date = input(null); readonly disabled = input(false); readonly expired = input(false); + readonly isGeneratingPdf = input(false); readonly selected = model(false); readonly viewQr = output(); diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.html b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.html index f58632c..2aa3e36 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.html +++ b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.html @@ -1,7 +1,14 @@ MIS TICKETS - + + @if (isGeneratingPdf()) { + + + Generando PDF... + + } + @if (activeTickets().length) { @@ -18,6 +25,7 @@ Descargar @@ -26,6 +34,7 @@ Compartir @@ -68,6 +77,7 @@ [ticketId]="ticket.id" [date]="formatDate(ticket)" [selected]="isSelected(ticket.id)" + [isGeneratingPdf]="isGeneratingPdf()" (selectedChange)="toggleTicket(ticket.id, $event)" (viewQr)="viewQr(ticket)" (download)="downloadTicket(ticket)" diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.scss b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.scss index d67e9c4..a659697 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.scss +++ b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.scss @@ -10,6 +10,35 @@ font-weight: 700; } +.tickets-list { + position: relative; +} + +.tickets-list__generating { + position: absolute; + z-index: 10; + inset: 0; + display: flex; + min-height: 180px; + align-items: center; + justify-content: center; + gap: 12px; + border-radius: 8px; + color: #26322a; + font-size: 14px; + font-weight: 700; + backdrop-filter: blur(2px); +} + +.tickets-list__generating-spinner { + width: 24px; + height: 24px; + border: 3px solid #dce8e0; + border-top-color: var(--tenant-primary, #009933); + border-radius: 50%; + animation: tickets-spinner 0.75s linear infinite; +} + .tickets-list__header { display: flex; min-height: 58px; @@ -170,6 +199,12 @@ } } +@keyframes tickets-spinner { + to { + transform: rotate(360deg); + } +} + @media (max-width: 767.98px) { .tickets-page__title { margin-top: 24px; diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.spec.ts b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.spec.ts index 361a183..e1d5e1d 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.spec.ts +++ b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.spec.ts @@ -26,6 +26,122 @@ const ticket = (overrides: Partial): TicketResponse => ({ }); describe('TicketsPage', () => { + it('shares the generated PDF as a file without text that Windows could prioritize', async () => { + const share = vi.fn().mockResolvedValue(undefined); + const canShare = vi.fn().mockReturnValue(true); + vi.stubGlobal('navigator', { canShare, share }); + + await TestBed.configureTestingModule({ + imports: [TicketsPage], + providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }], + }) + .overrideComponent(TicketsPage, { + set: { + providers: [ + { + provide: TicketService, + useValue: { + getTickets: () => Promise.resolve([ticket({})]), + downloadPdf: () => Promise.resolve(new Blob(['pdf-content'])), + }, + }, + ], + }, + }) + .compileComponents(); + + const fixture = TestBed.createComponent(TicketsPage); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const page = fixture.nativeElement as HTMLElement; + page.querySelector('[aria-label="Compartir ticket"]')?.click(); + + await vi.waitFor(() => expect(share).toHaveBeenCalledOnce()); + + const shareData = share.mock.calls[0][0] as ShareData; + const [sharedFile] = shareData.files ?? []; + + expect(Object.keys(shareData)).toEqual(['files']); + expect(canShare).toHaveBeenCalledWith({ files: [sharedFile] }); + expect(sharedFile).toBeInstanceOf(File); + expect(sharedFile.name).toBe('tickets_1.pdf'); + expect(sharedFile.type).toBe('application/pdf'); + expect(sharedFile.size).toBeGreaterThan(0); + + vi.unstubAllGlobals(); + }); + + it('shows a loading overlay and disables PDF actions while the file is generated', async () => { + let resolvePdf!: (pdf: Blob) => void; + const downloadPdf = vi.fn( + () => + new Promise((resolve) => { + resolvePdf = resolve; + }), + ); + const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:ticket'); + const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + let downloadedFilename = ''; + const linkClick = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(function (this: HTMLAnchorElement) { + downloadedFilename = this.download; + }); + + await TestBed.configureTestingModule({ + imports: [TicketsPage], + providers: [{ provide: ToastService, useValue: { danger: vi.fn() } }], + }) + .overrideComponent(TicketsPage, { + set: { + providers: [ + { + provide: TicketService, + useValue: { + getTickets: () => Promise.resolve([ticket({})]), + downloadPdf, + }, + }, + ], + }, + }) + .compileComponents(); + + const fixture = TestBed.createComponent(TicketsPage); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const page = fixture.nativeElement as HTMLElement; + page.querySelector('[aria-label="Descargar ticket"]')?.click(); + fixture.detectChanges(); + + expect(page.querySelector('.tickets-list__generating')?.textContent).toContain( + 'Generando PDF...', + ); + expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('true'); + expect(page.querySelector('[aria-label="Descargar ticket"]')?.disabled).toBe( + true, + ); + expect(page.querySelector('[aria-label="Compartir ticket"]')?.disabled).toBe( + true, + ); + + resolvePdf(new Blob(['pdf'], { type: 'application/pdf' })); + await fixture.whenStable(); + await vi.waitFor(() => expect(downloadedFilename).toBe('tickets_1.pdf')); + fixture.detectChanges(); + + expect(page.querySelector('.tickets-list__generating')).toBeNull(); + expect(page.querySelector('.tickets-list')?.getAttribute('aria-busy')).toBe('false'); + + createObjectUrl.mockRestore(); + revokeObjectUrl.mockRestore(); + linkClick.mockRestore(); + }); + it('hides the active tickets header when there are no available tickets', async () => { await TestBed.configureTestingModule({ imports: [TicketsPage], diff --git a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts index 9a73e5b..90f033b 100644 --- a/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts +++ b/src/app/features/store/pages/account-page/pages/tickets-page/tickets-page.ts @@ -27,6 +27,7 @@ export class TicketsPage implements OnInit { ); protected readonly selectedIds = signal>(new Set()); protected readonly isLoading = signal(true); + protected readonly isGeneratingPdf = signal(false); protected readonly allSelected = computed( () => this.activeTickets().length > 0 && this.selectedIds().size === this.activeTickets().length, @@ -118,42 +119,56 @@ export class TicketsPage implements OnInit { } private async downloadTickets(tickets: TicketResponse[]): Promise { - try { - const pdf = await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id)); - this.downloadPdf(pdf); - } catch { - this.toastService.danger('No se pudo generar el PDF de los tickets.'); - } + const pdf = await this.generateTicketsPdf(tickets); + if (pdf) this.downloadPdf(pdf, this.pdfFilename(tickets)); } private async shareTickets(tickets: TicketResponse[]): Promise { + const pdf = await this.generateTicketsPdf(tickets); + if (!pdf) return; + try { - const pdf = await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id)); - const file = new File([pdf], 'mis-tickets.pdf', { type: 'application/pdf' }); + const filename = this.pdfFilename(tickets); + const file = new File([pdf], filename, { type: 'application/pdf' }); if (navigator.canShare?.({ files: [file] })) { - await navigator.share({ - files: [file], - title: 'Mis tickets', - text: 'Te comparto mis tickets digitales.', - }); + await navigator.share({ files: [file] }); return; } - this.downloadPdf(pdf); + this.downloadPdf(pdf, filename); this.toastService.success('Tu navegador no permite compartir archivos; descargamos el PDF.'); } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return; - this.toastService.danger('No se pudo generar el PDF de los tickets.'); + this.toastService.danger('No se pudo compartir el PDF de los tickets.'); } } - private downloadPdf(pdf: Blob): void { + private async generateTicketsPdf(tickets: TicketResponse[]): Promise { + if (this.isGeneratingPdf()) return null; + + this.isGeneratingPdf.set(true); + + try { + return await this.ticketService.downloadPdf(tickets.map((ticket) => ticket.id)); + } catch { + this.toastService.danger('No se pudo generar el PDF de los tickets.'); + return null; + } finally { + this.isGeneratingPdf.set(false); + } + } + + private pdfFilename(tickets: TicketResponse[]): string { + return `tickets_${tickets.map((ticket) => ticket.id).join('_')}.pdf`; + } + + private downloadPdf(pdf: Blob, filename: string): void { const url = URL.createObjectURL(pdf); const link = document.createElement('a'); link.href = url; - link.download = 'mis-tickets.pdf'; + link.download = filename; document.body.appendChild(link); link.click(); link.remove();