Add PDF ticket generation and download feature

Introduces PDF ticket generation using html2canvas and jsPDF, including a new PdfTicketComponent for ticket rendering and a PdfService for PDF creation. Updates purchase success flow to allow users to download tickets as PDFs, adds progress feedback, and includes a test route and component for development. Also refactors order form with a fake fill helper and improves UI details.
This commit is contained in:
2025-11-26 11:54:42 +01:00
parent ea9912d048
commit 624ff820da
15 changed files with 696 additions and 15 deletions

View File

@@ -77,7 +77,8 @@ import { PayForOrderComponent } from './pay-for-order/pay-for-order.component';
import { CancelOrderDialog } from './cancel-order/cancel-order.dialog';
import { PricelistComponent } from './pricelist/pricelist.component';
import { TheaterLayoutDesignerComponent } from './theater-layout-designer/theater-layout-designer.component';
import { PdfTicketComponent } from './pdf-ticket/pdf-ticket.component';
import { TestComponent } from './test/test.component';
@@ -131,6 +132,7 @@ import { TheaterLayoutDesignerComponent } from './theater-layout-designer/theate
CancelOrderDialog,
PricelistComponent,
TheaterLayoutDesignerComponent,
TestComponent,
],
imports: [
AppRoutingModule,
@@ -165,7 +167,8 @@ import { TheaterLayoutDesignerComponent } from './theater-layout-designer/theate
MatTableModule,
MatSelectModule,
MatSortModule,
],
PdfTicketComponent
],
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(

View File

@@ -12,11 +12,13 @@ import { PayForOrderComponent } from './pay-for-order/pay-for-order.component';
import { StatisticsComponent } from './statistics/statistics.component';
import { PricelistComponent } from './pricelist/pricelist.component';
import { TheaterLayoutDesignerComponent } from './theater-layout-designer/theater-layout-designer.component';
import { TestComponent } from './test/test.component';
const routes: Routes = [
// Seiten ohne Layout
{ path: 'landing', component: HomeComponent },
{ path: 'poc-model', component: PocModelComponent, data: { allowMobile: true } },
{ path: 'poc-model', component: PocModelComponent, data: { allowMobile: true, roles: ['employee', 'admin'] }, canActivate: [AuthGuard] },
{ path: 'test', component: TestComponent, data: { allowMobile: true, roles: ['employee', 'admin'] }, canActivate: [AuthGuard] },
// Seiten mit MainLayout
{

View File

@@ -158,7 +158,10 @@
<!-- Card Name -->
<mat-form-field class="w-full">
<mat-label>Name des Besitzers</mat-label>
<input matInput formControlName="cardName" />
<input
matInput
formControlName="cardName"
/>
@if (fPayment['cardName'].hasError('minlength')) { <mat-error>Mindestens 3 Zeichen</mat-error> }
</mat-form-field>
@@ -178,14 +181,19 @@
<mat-form-field class="flex-1">
<mat-label>CVV</mat-label>
<input matInput type="password" maxlength="4" formControlName="cvv" />
<input
matInput
type="password"
maxlength="4"
formControlName="cvv"
/>
@if (fPayment['cvv'].hasError('pattern')) { <mat-error>34 Ziffernt</mat-error> }
</mat-form-field>
</div>
<!-- Info -->
<div class="flex w-full space-x-2 mt-2 items-center">
<mat-icon class="material-symbols-outlined opacity-50" style="font-size: 32px; width: 32px; height: 32px">
<mat-icon class="material-symbols-outlined opacity-50" style="font-size: 32px; width: 32px; height: 32px" (click)="fakeFormFill()">
encrypted
</mat-icon>
<p class="text-sm opacity-75">

View File

@@ -412,4 +412,13 @@ export class OrderComponent {
this.orderState.set({ status: 'idle' });
this.cancelReservation();
}
fakeFormFill() {
this.paymentForm.patchValue({
cardNumber: '4111 1111 1111 1111',
cardName: 'Max Mustermann',
expiry: '12/30',
cvv: '123'
});
}
}

View File

@@ -0,0 +1,117 @@
.ticket {
width: 210mm;
height: 99mm;
background: white;
overflow: hidden;
font-family: 'Arial', sans-serif;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin-bottom: 20px;
position: relative;
}
.ticket-header {
height: 15mm;
background: linear-gradient(to right, #6366f1, #db2777);
display: flex;
justify-content: center;
align-items: flex-start;
text-align: center;
}
.ticket-header h1 {
margin: 0;
color: white;
font-size: 28px;
font-weight: bold;
letter-spacing: 2px;
}
.ticket-body {
padding: 25px;
}
.movie-title {
font-size: 26px;
font-weight: bold;
margin-bottom: 10px;
position: relative;
padding-bottom: 6px;
}
.title-underline {
position: absolute;
bottom: -2px;
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(to right, #6366f1, #db2777);
border-radius: 2px;
}
.ticket-flex {
padding-left: 1mm;
display: flex;
justify-content: space-between;
}
.ticket-info {
display: flex;
justify-content: space-between;
gap: 30px;
margin-top: 15px;
margin-bottom: 5px;
width: 100%;
}
.info-section {
flex: 1;
}
.info-row {
display: flex;
margin-bottom: 5mm;
font-size: 14px;
}
.info-row .label {
font-weight: bold;
color: #555;
min-width: 90px;
}
.info-row .value {
color: #222;
}
.info-row .value.price {
color: #6366f1;
}
.ticket-footer {
display: flex;
justify-content: space-between;
padding: 20px 25px;
align-items: center;
border-top: 1px dashed #ccc;
}
.ticket-number {
font-size: 12px;
color: #666;
font-family: 'Courier New', monospace;
}
.perforation {
position: absolute;
right: 20%;
top: 0;
bottom: 0;
width: 1px;
background-image: linear-gradient(to bottom, #ccc 50%, transparent 50%);
background-size: 1px 8px;
}

View File

@@ -0,0 +1,63 @@
<div class="ticket">
<div class="ticket-header">
<h1>INFINIMOTION EINTRITTSKARTE</h1>
</div>
<div class="ticket-body">
<div class="movie-title">
{{ ticket.show.movie.title }}
<div class="title-underline"></div>
</div>
<div class="ticket-flex">
<div class="ticket-info">
<div class="info-section">
<div class="info-row">
<span class="label">Datum:</span>
<span class="value">{{ formatDate(ticket.show.start) }}</span>
</div>
<div class="info-row">
<span class="label">Uhrzeit:</span>
<span class="value">{{ formatTime(ticket.show.start) }}</span>
</div>
<div class="info-row">
<span class="label">Preis:</span>
<span class="value price">{{ formatPrice(ticket.seat.row.category.price) }}</span>
</div>
</div>
<div class="info-section">
<div class="info-row">
<span class="label">Saal:</span>
<span class="value">{{ ticket.show.hall.name }}</span>
</div>
<div class="info-row">
<span class="label">Sitz:</span>
<span class="value">{{ formatSeat(ticket.seat.row.position, ticket.seat.position) }}</span>
</div>
<div class="info-row">
<span class="label">Tarif:</span>
<span class="value">{{ ticket.seat.row.category.name }}</span>
</div>
</div>
</div>
<qrcode class="mt-2" qrdata="{{ ticket.code }}" [errorCorrectionLevel]="'M'"></qrcode>
</div>
</div>
<div class="ticket-footer">
<div class="ticket-number">{{ formatTicketNr(ticket.code) }}</div>
<div class="ticket-number">{{ formatOrderNr(ticket.order.code) }}</div>
<div class="ticket-number">{{ formatPerformanceNr(ticket.show.id) }}</div>
</div>
<div class="perforation"></div>
</div>

View File

@@ -0,0 +1,62 @@
import { Component, Input } from '@angular/core';
import { Eintrittskarte } from '@infinimotion/model-frontend';
import { QRCodeComponent } from "angularx-qrcode";
@Component({
selector: 'app-pdf-ticket',
standalone: true,
templateUrl: './pdf-ticket.component.html',
styleUrl: './pdf-ticket.component.css',
imports: [QRCodeComponent],
})
export class PdfTicketComponent {
@Input() ticket!: Eintrittskarte;
formatDate(date: Date | string | number): string {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric'
}).format(d);
}
formatTime(date: Date | string | number): string {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
return new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit'
}).format(d) + ' Uhr';
}
formatPrice(price: number): string {
return `${(price / 100).toFixed(2)}`;
}
formatSeat(row: number, seat: number): string {
return "Reihe " + this.convertIntoRowName(row) + ", Platz " + seat;
}
private convertIntoRowName(n: number): string {
return String.fromCharCode(64 + n);
}
formatTicketNr(code: string): string {
return "Ticket-Nr: " + code;
}
formatOrderNr(code: string): string {
return "Bestellungs-Nr: " + code;
}
formatPerformanceNr(id: number): string {
return "Vorstellungs-Nr: " + id;
}
}

111
src/app/pdf.service.ts Normal file
View File

@@ -0,0 +1,111 @@
import { Injectable, ComponentRef, ViewContainerRef, ApplicationRef, createComponent, EnvironmentInjector, inject, signal } from '@angular/core';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import { Eintrittskarte } from '@infinimotion/model-frontend';
@Injectable({
providedIn: 'root',
})
export class PdfService {
private ticketsGreatedSignal = signal(0);
private totalTicketsSignal = signal(0);
readonly ticketsGreated = this.ticketsGreatedSignal.asReadonly();
readonly totalTickets = this.totalTicketsSignal.asReadonly();
appRef = inject(ApplicationRef);
injector = inject(EnvironmentInjector);
async genTicket(tickets: Eintrittskarte[], ticketComponent: any): Promise<void> {
if (tickets.length === 0) {
throw new Error('Keine Tickets zum Generieren vorhanden');
}
this.ticketsGreatedSignal.set(0)
this.totalTicketsSignal.set(tickets.length)
// Container für temporäres Rendering erstellen
const container = document.createElement('div');
container.style.position = 'fixed';
container.style.left = '-9999px';
container.style.top = '0';
document.body.appendChild(container);
const componentRefs: ComponentRef<any>[] = [];
try {
// Ticket-Format: 210mm x 99mm
const ticketWidthMM = 210;
const ticketHeightMM = 99;
const pdf = new jsPDF({
orientation: 'landscape',
unit: 'mm',
format: [ticketWidthMM, ticketHeightMM]
});
for (let i = 0; i < tickets.length; i++) {
const ticket = tickets[i];
// Komponente dynamisch erstellen
const componentRef = createComponent(ticketComponent, {
environmentInjector: this.injector
});
// Ticket-Daten an die Komponente übergeben
(componentRef.instance as any).ticket = ticket;
// Komponente ins DOM einfügen
this.appRef.attachView(componentRef.hostView);
container.appendChild(componentRef.location.nativeElement);
componentRefs.push(componentRef);
// Change Detection triggern
componentRef.changeDetectorRef.detectChanges();
// Kurz warten, damit alles gerendert ist
await new Promise(requestAnimationFrame);
// HTML zu Canvas konvertieren
const canvas = await html2canvas(componentRef.location.nativeElement, {
scale: 2,
backgroundColor: '#ffffff',
logging: false,
useCORS: true
});
const imgData = canvas.toDataURL('image/png');
if (i > 0) {
pdf.addPage([ticketWidthMM, ticketHeightMM], 'landscape');
}
// Bild ins PDF einfügen
pdf.addImage(imgData, 'PNG', 0, 0, ticketWidthMM, ticketHeightMM);
this.ticketsGreatedSignal.set(this.ticketsGreatedSignal() + 1)
}
const fileName = this.generateFileName(tickets);
pdf.save(fileName);
} catch (error) {
console.error('Fehler beim Generieren des PDFs:', error);
throw new Error('Das PDF konnte nicht erstellt werden. Bitte versuche es erneut.');
} finally {
componentRefs.forEach(ref => {
this.appRef.detachView(ref.hostView);
ref.destroy();
});
document.body.removeChild(container);
}
}
private generateFileName(tickets: Eintrittskarte[]): string {
const orderCode = tickets[0].order.code;
const timestamp = new Date().getTime();
return `Ticket_${orderCode}_${timestamp}.pdf`;
}
}

View File

@@ -4,7 +4,7 @@
<app-ticket-list [tickets]="tickets()" class="w-8/10 my-4"></app-ticket-list>
<button mat-button disabled="true" matButton="filled" class="success-button w-80 mt-4">{{ buttonText }}</button>
<button mat-button type="button" [disabled]="isGenerating" matButton="filled" class="success-button w-80 mt-4" (click)="downloadTickets()">{{ getButtonText() }}</button>
<button routerLink="/schedule" type="button" mat-button matButton="outlined" color="accent" class="success-button w-80 mt-1">Zur Programmauswahl</button>
</div>

View File

@@ -1,5 +1,9 @@
import { Eintrittskarte } from '@infinimotion/model-frontend';
import { Component, input } from '@angular/core';
import { ChangeDetectorRef, Component, inject, input } from '@angular/core';
import { PdfService } from '../pdf.service';
import { PdfTicketComponent } from '../pdf-ticket/pdf-ticket.component';
import { LoadingService } from '../loading.service';
import { MatSnackBar } from '@angular/material/snack-bar';
@Component({
selector: 'app-purchase-success',
@@ -14,13 +18,51 @@ export class PurchaseSuccessComponent {
infoText!: string;
buttonText!: string;
ngOnInit(): void {
this.infoText = this.moreThanOne()?
'Ihre Sitzplätze wurden erfolgreich gebucht.' :
'Ihr Sitzplatz wurden erfolgreich gebucht.';
isGenerating = false;
this.buttonText = this.moreThanOne()?
'Tickets herunterladen' :
'Ticket herunterladen';
public pdfService = inject(PdfService);
private loadingService = inject(LoadingService);
constructor(private snackBar: MatSnackBar) {}
ngOnInit(): void {
this.infoText = this.moreThanOne()
? 'Ihre Sitzplätze wurden erfolgreich gebucht.'
: 'Ihr Sitzplatz wurde erfolgreich gebucht.';
this.buttonText = this.moreThanOne()
? 'Tickets herunterladen'
: 'Ticket herunterladen';
}
async downloadTickets() {
this.loadingService.show();
this.isGenerating = true;
const message = "PDF wird erstellt. Dieser Prozess kann einige Sekunden dauern.";
this.snackBar.open(message, 'Schließen', {
duration: 10000,
horizontalPosition: 'right',
verticalPosition: 'top'
});
try {
await this.pdfService.genTicket(this.tickets(), PdfTicketComponent)
this.loadingService.hide();
} catch (error) {
console.error('Fehler beim PDF erstellen:', error);
this.loadingService.showError('Es gab einen Fehler beim Erstellen des PDFs. Bitte versuche es erneut.');
} finally {
this.isGenerating = false;
}
}
getButtonText() {
if (this.isGenerating ) {
if (this.moreThanOne()) {
return "Tickets werden erstellt... " + this.pdfService.ticketsGreated() + "/" + this.pdfService.totalTickets();
}
return "Ticket wird erstellt...";
}
return this.buttonText;
}
}

View File

View File

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-test',
standalone: false,
templateUrl: './test.component.html',
styleUrl: './test.component.css',
})
export class TestComponent {
}