Add reservation and purchase functionality

Introduces ReservationSuccess, ReservationFailed, PurchaseSuccess, PurchaseFailed, TicketSmall, and TicketList components for handling and displaying reservation and purchase outcomes. Updates order flow logic in OrderComponent to support reservation and purchase states, disables/enables form inputs during submission, and integrates new UI feedback. Also adds angularx-qrcode dependency and updates @infinimotion/model-frontend version.
This commit is contained in:
2025-11-14 17:56:33 +01:00
parent f165a91e3c
commit 50cac8ac24
30 changed files with 821 additions and 107 deletions

View File

@@ -1,6 +1,6 @@
<div class="w-full h-full relative">
@if (loadingService.loading$ | async){
@if (!performance() && (loadingService.loading$ | async)){
<div class="w-full h-full flex items-center justify-center">
<mat-progress-spinner
mode="indeterminate"
@@ -17,6 +17,16 @@
></app-performance-info>
</div>
@if(isSubmitting) {
<div class="absolute top-55 z-25 w-full px-6 my-auto">
<mat-progress-spinner
class="m-auto"
mode="indeterminate"
diameter="100"
></mat-progress-spinner>
</div>
}
<mat-stepper orientation="horizontal" linear="true" [disableRipple]="true" (selectionChange)="onStepChange($event)" #stepper>
<mat-step>
<ng-template matStepLabel>Warenkorb</ng-template>
@@ -48,8 +58,8 @@
<!-- Buttons -->
<div class="flex space-x-5 mt-10">
<button mat-button matButton="outlined" matStepperNext class="w-1/2" [disabled]="totalSeats()==0">Reservieren</button>
<button mat-button matButton="filled" matStepperNext class="w-1/2" [disabled]="totalSeats()==0">Buchen</button>
<button mat-button matButton="outlined" matStepperNext class="w-1/2" [disabled]="totalSeats()==0" (click)="reservationClicked()">Reservieren</button>
<button mat-button matButton="filled" matStepperNext class="w-1/2" [disabled]="totalSeats()==0" (click)="purchaseClicked()">Kaufen</button>
</div>
</mat-step>
@@ -59,32 +69,45 @@
<div class="performance-info-space"></div>
<!-- Name -->
<mat-form-field class="w-full mt-8">
<mat-label>Name</mat-label>
<input matInput formControlName="name" placeholder="Max Mustermann" />
@if (fData['name'].hasError('minlength')) { <mat-error>Mindestens 3 Zeichen</mat-error> }
</mat-form-field>
@if (seatsReserved && !isSubmitting) {
<div class="h-4"></div>
@if (successful) {
<app-reservation-success [order]="this.createdOrder"></app-reservation-success>
} @else {
<app-reservation-failed></app-reservation-failed>
}
}
@else {
<!-- E-Mail -->
<mat-form-field class="w-full mt-2">
<mat-label>E-Mail Adresse</mat-label>
<input matInput formControlName="email" placeholder="max.mustermann@edu.fhdw.de" />
@if (fData['email'].hasError('email')) { <mat-error>Ungültige E-Mail-Adresse</mat-error> }
</mat-form-field>
<!-- Name -->
<mat-form-field class="w-full mt-8">
<mat-label>Name</mat-label>
<input matInput formControlName="name" placeholder="Max Mustermann" />
@if (fData['name'].hasError('minlength')) { <mat-error>Mindestens 3 Zeichen</mat-error> }
</mat-form-field>
<!-- Checkbox -->
<div class="w-full my-4">
<mat-checkbox required formControlName="accept" class="checkbox-invalid" [class]="{ 'checkbox-invalid': submitted && fData['accept'].hasError('required') }">
Ich akzeptiere die AGB und die Datenbestimmung
</mat-checkbox>
</div>
<!-- E-Mail -->
<mat-form-field class="w-full mt-2">
<mat-label>E-Mail Adresse</mat-label>
<input matInput formControlName="email" placeholder="max.mustermann@edu.fhdw.de" />
@if (fData['email'].hasError('email')) { <mat-error>Ungültige E-Mail-Adresse</mat-error> }
</mat-form-field>
<!-- Checkbox -->
<div class="w-full my-4">
<mat-checkbox required formControlName="accept" class="checkbox-invalid" [class]="{ 'checkbox-invalid': submitted && fData['accept'].hasError('required') }">
Ich akzeptiere die AGB und die Datenbestimmung
</mat-checkbox>
</div>
<!-- Buttons -->
<div class="flex space-x-5 mt-10">
<button type="button" mat-button matButton="outlined" (click)="stepper.reset()" class="w-1/3" [disabled]="isSubmitting">Zurück</button>
<button type="submit" mat-button matButton="filled" (click)="nextPhaseButtonClicked(stepper)" class="w-2/3" [disabled]="isSubmitting">{{ secondPhaseButtonText }}</button>
</div>
}
<!-- Buttons -->
<div class="flex space-x-5 mt-10">
<button type="button" mat-button matButton="outlined" (click)="stepper.reset()" class="w-1/3">Zurück</button>
<button type="submit" mat-button matButton="filled" matStepperNext (click)="stupidCheckboxWorkaround()" class="w-2/3">Sitzplätze reservieren</button>
</div>
</form>
</mat-step>
<mat-step [stepControl]="paymentForm">
@@ -93,66 +116,77 @@
<div class="performance-info-space"></div>
<!-- Card Number -->
<mat-form-field class="w-full mt-8">
<mat-label>Kartennummer</mat-label>
<input
matInput
formControlName="cardNumber"
mask="0000 0000 0000 0000"
placeholder="1111 2222 3333 4444"
/>
@if (fPayment['cardNumber'].hasError('pattern')) { <mat-error>Ungültige Kartennummer</mat-error> }
@if (seatsPurchased && !isSubmitting) {
<div class="h-4"></div>
@if (successful) {
<app-purchase-success [tickets]="createdTickets"></app-purchase-success>
} @else {
<app-purchase-failed></app-purchase-failed>
}
}
@else {
</mat-form-field>
<!-- Card Name -->
<mat-form-field class="w-full">
<mat-label>Kartenname</mat-label>
<input matInput formControlName="cardName" />
@if (fPayment['cardName'].hasError('minlength')) { <mat-error>Mindestens 3 Zeichen</mat-error> }
</mat-form-field>
<!-- Expiry & CVV -->
<div class="flex space-x-4">
<mat-form-field class="flex-1">
<mat-label>Gültig bis (MM/YY)</mat-label>
<!-- Card Number -->
<mat-form-field class="w-full mt-8">
<mat-label>Kartennummer</mat-label>
<input
matInput
formControlName="expiry"
mask="00/00"
placeholder="MM/YY"
[dropSpecialCharacters]="false"
formControlName="cardNumber"
mask="0000 0000 0000 0000"
placeholder="1111 2222 3333 4444"
/>
@if (fPayment['expiry'].hasError('pattern')) { <mat-error>Ungültiges Format</mat-error> }
@if (fPayment['cardNumber'].hasError('pattern')) { <mat-error>Ungültige Kartennummer</mat-error> }
</mat-form-field>
<mat-form-field class="flex-1">
<mat-label>CVV</mat-label>
<input matInput type="password" maxlength="4" formControlName="cvv" />
@if (fPayment['cvv'].hasError('pattern')) { <mat-error>34 Ziffernt</mat-error> }
<!-- Card Name -->
<mat-form-field class="w-full">
<mat-label>Kartenname</mat-label>
<input matInput formControlName="cardName" />
@if (fPayment['cardName'].hasError('minlength')) { <mat-error>Mindestens 3 Zeichen</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">
encrypted
</mat-icon>
<p class="text-sm opacity-75">
Ihre Zahlung wird sicher über unsere Partner verarbeitet.<br>Wir speichern keine Zahlungsinformationen.
</p>
</div>
<!-- Expiry & CVV -->
<div class="flex space-x-4">
<mat-form-field class="flex-1">
<mat-label>Gültig bis (MM/YY)</mat-label>
<input
matInput
formControlName="expiry"
mask="00/00"
placeholder="MM/YY"
[dropSpecialCharacters]="false"
/>
@if (fPayment['expiry'].hasError('pattern')) { <mat-error>Ungültiges Format</mat-error> }
</mat-form-field>
<!-- Buttons -->
<div class="flex space-x-4 mt-8">
<button mat-stroked-button color="primary" matStepperPrevious type="button" class="w-1/3">
Zurück
</button>
<button mat-flat-button color="accent" class="w-2/3" matStepperNext type="submit">
{{ getPriceDisplay(totalPrice()) }} jetzt bezahlen
</button>
</div>
<mat-form-field class="flex-1">
<mat-label>CVV</mat-label>
<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">
encrypted
</mat-icon>
<p class="text-sm opacity-75">
Ihre Zahlung wird sicher über unsere Partner verarbeitet.<br>Wir speichern keine Zahlungsinformationen.
</p>
</div>
<!-- Buttons -->
<div class="flex space-x-4 mt-8">
<button mat-stroked-button color="primary" matStepperPrevious type="button" [disabled]="isSubmitting" class="w-1/3">
Zurück
</button>
<button mat-flat-button color="accent" matStepperNext type="submit" [disabled]="isSubmitting" (click)="makePurchase()" class="w-2/3">
{{ getPriceDisplay(totalPrice()) }} jetzt bezahlen
</button>
</div>
}
</form>
</mat-step>

View File

@@ -1,9 +1,12 @@
import { SelectedSeatsService } from './../selected-seats.service';
import { LoadingService } from './../loading.service';
import { Sitzkategorie, Vorstellung } from '@infinimotion/model-frontend';
import { Bestellung, Eintrittskarte, Sitzkategorie, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend';
import { Component, computed, inject, input } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { StepperSelectionEvent } from '@angular/cdk/stepper';
import { HttpService } from '../http.service';
import { catchError, tap, finalize } from 'rxjs';
import { MatStepper } from '@angular/material/stepper';
@Component({
selector: 'app-order',
@@ -17,8 +20,17 @@ export class OrderComponent {
submitted = false;
performance = input<Vorstellung>();
seatCategories = input.required<Sitzkategorie[]>();
loadingService = inject(LoadingService);
private httpService = inject(HttpService)
private selectedSeatsService = inject(SelectedSeatsService);
constructor(private fb: FormBuilder) {}
// Form-Validation
ngOnInit(): void {
this.paymentForm = this.fb.group({
cardNumber: ['', [Validators.required, Validators.pattern(/^\d{16}$/)]],
@@ -37,23 +49,31 @@ export class OrderComponent {
get fPayment() { return this.paymentForm.controls; }
onSubmit() {
if (this.dataForm.invalid) return;
if (this.paymentForm.invalid) return;
console.log('Zahlungsdaten:', this.paymentForm.value);
}
onStepChange(event: StepperSelectionEvent) {
this.submitted = false;
if(event.selectedIndex != 0) {
this.selectedSeatsService.setSeatIsSelectableFalse()
} else {
this.selectedSeatsService.setSeatIsSelectableTrue()
}
}
stupidCheckboxWorkaround() {
nextPhaseButtonClicked(stepper: MatStepper) {
this.submitted = true;
if (this.dataForm.invalid) return;
if (this.submissionMode === "reservation") {
this.makeReservation();
} else if (this.submissionMode === "purchase") {
stepper.next();
}
}
performance = input<Vorstellung>();
seatCategories = input.required<Sitzkategorie[]>();
loadingService = inject(LoadingService);
private selectedSeatsService = inject(SelectedSeatsService);
totalPrice = computed(() =>
this.selectedSeatsService.getSelectedSeatsList().reduce((sum, seat) => sum + seat.row.category.price, 0)
@@ -67,4 +87,138 @@ export class OrderComponent {
return `${(price / 100).toFixed(2)}`;
}
isSubmitting: boolean = false;
secondPhaseButtonText: string = "Loading..."
submissionMode!: 'reservation' | 'purchase';
seatsReserved: boolean = false;
seatsPurchased: boolean = false;
successful: boolean = false;
reservationClicked() {
this.submissionMode = "reservation";
if (this.totalSeats() > 1) {
this.secondPhaseButtonText = "Sitzplätze reservieren"
} else {
this.secondPhaseButtonText = "Sitzplatz reservieren"
}
}
purchaseClicked() {
this.submissionMode = "purchase"
this.secondPhaseButtonText = "Weiter zur Zahlung"
}
makeReservation() {
this.loadingService.show();
this.disableInputs()
const order = this.generateNewOrderObject(this.dataForm.value.email, false);
const seats = this.selectedSeatsService.getSelectedSeatsList();
const performance = this.performance()!;
this.successful = true;
this.sendToBackend(order, seats, performance);
this.seatsReserved = true;
}
makePurchase() {
this.loadingService.show();
this.disableInputs()
const order = this.generateNewOrderObject(this.dataForm.value.email, true);
const seats = this.selectedSeatsService.getSelectedSeatsList();
const performance = this.performance()!;
this.successful = true;
this.sendToBackend(order, seats, performance);
this.seatsPurchased = true;
}
createdOrder!: Bestellung;
createdTickets!: Eintrittskarte[];
sendToBackend(order: Bestellung, seats: Sitzplatz[], performance: Vorstellung) {
this.httpService.addOrder(order).pipe(
tap(createdOrder => {
this.createdOrder = createdOrder;
const ticketCreations = seats.map(seat => {
const ticket = this.generateNewTicketObject(performance, seat, createdOrder);
return this.httpService.addTicket(ticket);
});
Promise.all(ticketCreations.map(obs => obs.toPromise()))
.then(createdTickets => {
this.createdTickets = createdTickets.filter(
(ticket): ticket is Eintrittskarte => ticket !== undefined
);
this.loadingService.hide();
this.enableInputs();
})
.catch(err => {
this.loadingService.showError(err);
this.successful = false;
console.error('Fehler beim Anlegen der Eintrittskarten', err);
});
}),
catchError(err => {
this.loadingService.showError(err);
this.successful = false;
console.error('Fehler beim Anlegen der Bestellung', err);
return [];
}),
finalize(() => {
this.enableInputs();
})
).subscribe();
}
private generateCode(length: number = 6): string {
const chars = "ABCDEFGHJKLMNPQRSUVWXYZ23456789";
let result = "";
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * chars.length);
result += chars[randomIndex];
}
return result;
}
private generateNewOrderObject(mail: string, isBooked: boolean): Bestellung {
return{
id: 0, // Wird durch Backend gesetzt
mail: mail,
code: this.generateCode(length=6),
reserved: new Date(),
booked: isBooked ? new Date() : null,
cancelled: null,
};
}
private generateNewTicketObject(show: Vorstellung, seat: Sitzplatz, order: Bestellung): Eintrittskarte {
return {
id: 0, // Wird durch Backend gesetzt
code: 'T' + this.generateCode(length=7),
show: show,
seat: seat,
order: order
};
}
private disableInputs() {
this.dataForm.disable();
this.paymentForm.disable();
this.isSubmitting = true;
}
private enableInputs() {
this.dataForm.enable();
this.paymentForm.enable();
this.isSubmitting = false;
}
}