Refactor order process to fix flicker bug

This commit is contained in:
2025-11-15 01:53:13 +01:00
parent 4ec3795697
commit e5707709bf
8 changed files with 366 additions and 337 deletions

View File

@@ -1,195 +1,195 @@
<div class="w-full h-full relative">
@if (!performance() && (loadingService.loading$ | async)){
<div class="w-full h-full flex items-center justify-center">
<mat-progress-spinner
mode="indeterminate"
diameter="50"
></mat-progress-spinner>
</div>
}
@else if (performance()) {
<div class="absolute top-18 z-20 w-full px-6">
<app-performance-info
class="w-full h-10"
[performance]="performance()!"
></app-performance-info>
</div>
@if(isSubmitting) {
<div class="absolute top-55 z-25 w-full px-6 my-auto">
@if (!performance() && (loadingService.loading$ | async)){
<div class="w-full h-full flex items-center justify-center">
<mat-progress-spinner
class="m-auto"
mode="indeterminate"
diameter="100"
diameter="50"
></mat-progress-spinner>
</div>
}
@else if (performance()) {
<mat-stepper orientation="horizontal" linear="true" [disableRipple]="true" (selectionChange)="onStepChange($event)" #stepper>
<mat-step>
<ng-template matStepLabel>Warenkorb</ng-template>
<div class="absolute top-18 z-20 w-full px-6">
<app-performance-info
class="w-full h-10"
[performance]="performance()!"
></app-performance-info>
</div>
<div class="performance-info-space"></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>
}
<!-- Seat-Selection Overview -->
<div class="mb-4 p-2">
@for (seatCategory of seatCategories(); track $index) {
<div class="h-2"></div>
<app-seat-selection [seatCategory]="seatCategory"></app-seat-selection>
}
@empty {
<app-no-seats-in-hall></app-no-seats-in-hall>
}
</div>
<mat-stepper orientation="horizontal" linear="true" [disableRipple]="true" (selectionChange)="onStepChange($event)" #stepper>
<mat-step>
<ng-template matStepLabel>Warenkorb</ng-template>
<mat-divider></mat-divider>
<div class="performance-info-space"></div>
<!-- Total Price -->
<div class="flex justify-between p-2 mt-1 items-baseline">
<p class="font-semibold text-lg">
Tickets gesamt:
</p>
<p class="font-semibold text-2xl bg-linear-to-r from-indigo-500 to-pink-600 bg-clip-text text-transparent">
{{ getPriceDisplay(totalPrice()) }}
</p>
</div>
<!-- Buttons -->
<div class="flex space-x-5 mt-10">
<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>
<mat-step [stepControl]="dataForm">
<form [formGroup]="dataForm">
<ng-template matStepLabel>Anschrift</ng-template>
<div class="performance-info-space"></div>
@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 {
<!-- 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>
<!-- 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>
<!-- Seat-Selection Overview -->
<div class="mb-4 p-2">
@for (seatCategory of seatCategories(); track $index) {
<div class="h-2"></div>
<app-seat-selection [seatCategory]="seatCategory"></app-seat-selection>
}
@empty {
<app-no-seats-in-hall></app-no-seats-in-hall>
}
</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>
<mat-divider></mat-divider>
}
</form>
</mat-step>
<mat-step [stepControl]="paymentForm">
<form [formGroup]="paymentForm" (ngSubmit)="onSubmit()">
<ng-template matStepLabel>Zahlung</ng-template>
<div class="performance-info-space"></div>
@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 {
<!-- 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> }
</mat-form-field>
<!-- Card Name -->
<mat-form-field class="w-full">
<mat-label>Name des Besitzers</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>
<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>
<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.
<!-- Total Price -->
<div class="flex justify-between p-2 mt-1 items-baseline">
<p class="font-semibold text-lg">
Tickets gesamt:
</p>
<p class="font-semibold text-2xl bg-linear-to-r from-indigo-500 to-pink-600 bg-clip-text text-transparent">
{{ getPriceDisplay(totalPrice()) }}
</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 class="flex space-x-5 mt-10">
<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>
}
</form>
</mat-step>
</mat-step>
</mat-stepper>
}
<mat-step [stepControl]="dataForm">
<form [formGroup]="dataForm">
<ng-template matStepLabel>Anschrift</ng-template>
<div class="performance-info-space"></div>
@if (isReservationSuccess() && !isSubmitting()) {
<div class="h-4"></div>
<app-reservation-success [order]="createdOrder()!"></app-reservation-success>
}
@else if (isReservationError() && !isSubmitting()) {
<div class="h-4"></div>
<app-reservation-failed></app-reservation-failed>
}
@else {
<!-- 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>
<!-- 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>
}
</form>
</mat-step>
<mat-step [stepControl]="paymentForm">
<form [formGroup]="paymentForm" (ngSubmit)="makePurchase()">
<ng-template matStepLabel>Zahlung</ng-template>
<div class="performance-info-space"></div>
@if (isPurchaseSuccess() && !isSubmitting()) {
<div class="h-4"></div>
<app-purchase-success [tickets]="createdTickets()"></app-purchase-success>
}
@else if (isPurchaseError() && !isSubmitting()) {
<div class="h-4"></div>
<app-purchase-failed></app-purchase-failed>
}
@else {
<!-- 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> }
</mat-form-field>
<!-- Card Name -->
<mat-form-field class="w-full">
<mat-label>Name des Besitzers</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>
<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>
<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()" class="w-2/3">
{{ getPriceDisplay(totalPrice()) }} jetzt bezahlen
</button>
</div>
}
</form>
</mat-step>
</mat-stepper>
}
</div>

View File

@@ -1,12 +1,23 @@
import { SelectedSeatsService } from './../selected-seats.service';
import { LoadingService } from './../loading.service';
import { Bestellung, Eintrittskarte, Sitzkategorie, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend';
import { Component, computed, inject, input } from '@angular/core';
import { Component, computed, DestroyRef, inject, input, signal } 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 { catchError, tap, finalize, switchMap, map, EMPTY, forkJoin } from 'rxjs';
import { MatStepper } from '@angular/material/stepper';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
type OrderState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'reservation-success'; order: Bestellung }
| { status: 'reservation-error'; error: any }
| { status: 'purchase-success'; tickets: Eintrittskarte[] }
| { status: 'purchase-error'; error: any };
type SubmissionMode = 'reservation' | 'purchase';
@Component({
selector: 'app-order',
@@ -15,182 +26,207 @@ import { MatStepper } from '@angular/material/stepper';
styleUrl: './order.component.css'
})
export class OrderComponent {
paymentForm!: FormGroup;
dataForm!: FormGroup;
submitted = false;
private fb = inject(FormBuilder);
private httpService = inject(HttpService);
private destroyRef = inject(DestroyRef);
readonly loadingService = inject(LoadingService);
readonly selectedSeatsService = inject(SelectedSeatsService);
performance = input<Vorstellung>();
seatCategories = input.required<Sitzkategorie[]>();
loadingService = inject(LoadingService);
private httpService = inject(HttpService)
private selectedSeatsService = inject(SelectedSeatsService);
paymentForm!: FormGroup;
dataForm!: FormGroup;
orderState = signal<OrderState>({ status: 'idle' });
submissionMode = signal<SubmissionMode | null>(null);
submitted = signal(false);
confetti: any;
constructor(private fb: FormBuilder) {}
totalPrice = this.selectedSeatsService.totalPrice;
totalSeats = this.selectedSeatsService.totalSeats;
isSubmitting = computed(() => this.orderState().status === 'submitting');
secondPhaseButtonText = computed(() => {
const mode = this.submissionMode();
if (!mode) return 'Loading...';
if (mode === 'reservation') {
return this.totalSeats() > 1 ? 'Sitzplätze reservieren' : 'Sitzplatz reservieren';
}
return 'Weiter zur Zahlung';
});
isReservationSuccess = computed(() =>
this.orderState().status === 'reservation-success'
);
isPurchaseSuccess = computed(() =>
this.orderState().status === 'purchase-success'
);
isReservationError = computed(() =>
this.orderState().status === 'reservation-error'
);
isPurchaseError = computed(() =>
this.orderState().status === 'purchase-error'
);
createdOrder = computed(() => {
const state = this.orderState();
return state.status === 'reservation-success' ? state.order : null;
});
createdTickets = computed(() => {
const state = this.orderState();
return state.status === 'purchase-success' ? state.tickets : [];
});
// Form-Validation
async ngOnInit() {
this.dataForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]],
accept: ['', Validators.requiredTrue],
});
this.paymentForm = this.fb.group({
cardNumber: ['', [Validators.required, Validators.pattern(/^\d{16}$/)]],
cardName: ['', [Validators.required, Validators.minLength(3)]],
expiry: ['', [Validators.required, Validators.pattern(/^(0[1-9]|1[0-2])\/\d{2}$/)]],
cvv: ['', [Validators.required, Validators.pattern(/^\d{3,4}$/)]],
});
this.dataForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]],
accept: ['', Validators.requiredTrue],
});
this.confetti = (await import('canvas-confetti')).default;
}
get fData() { return this.dataForm.controls; }
get fPayment() { return this.paymentForm.controls; }
onSubmit() {
if (this.dataForm.invalid) return;
if (this.paymentForm.invalid) return;
onStepChange(event: StepperSelectionEvent) {
this.submitted.set(false);
this.selectedSeatsService.setSeatSelectable(event.selectedIndex === 0);
}
onStepChange(event: StepperSelectionEvent) {
this.submitted = false;
reservationClicked() {
this.submissionMode.set('reservation');
}
if(event.selectedIndex != 0) {
this.selectedSeatsService.setSeatIsSelectableFalse()
} else {
this.selectedSeatsService.setSeatIsSelectableTrue()
}
purchaseClicked() {
this.submissionMode.set('purchase');
}
nextPhaseButtonClicked(stepper: MatStepper) {
this.submitted = true;
if (this.dataForm.invalid) return;
this.submitted.set(true);
if (this.submissionMode === "reservation") {
if (this.dataForm.invalid) {
return;
}
if (this.submissionMode() === 'reservation') {
this.makeReservation();
} else if (this.submissionMode === "purchase") {
} else if (this.submissionMode() === 'purchase') {
stepper.next();
}
}
totalPrice = computed(() =>
this.selectedSeatsService.getSelectedSeatsList().reduce((sum, seat) => sum + seat.row.category.price, 0)
);
totalSeats = computed(() =>
this.selectedSeatsService.getSelectedSeatsList().length
);
getPriceDisplay(price: number): string {
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.orderState.set({ status: 'submitting' });
this.loadingService.show();
this.disableInputs()
this.disableForms();
const order = this.generateNewOrderObject(this.dataForm.value.email, false);
const seats = this.selectedSeatsService.getSelectedSeatsList();
const seats = this.selectedSeatsService.selectedSeats();
const performance = this.performance()!;
this.sendToBackend(order, seats, performance);
this.seatsReserved = true;
this.submitOrder(order, seats, performance, 'reservation');
}
makePurchase() {
if (this.paymentForm.invalid) {
return;
}
this.orderState.set({ status: 'submitting' });
this.loadingService.show();
this.disableInputs()
this.disableForms();
const order = this.generateNewOrderObject(this.dataForm.value.email, true);
const seats = this.selectedSeatsService.getSelectedSeatsList();
const seats = this.selectedSeatsService.selectedSeats();
const performance = this.performance()!;
this.sendToBackend(order, seats, performance);
this.seatsPurchased = true;
this.submitOrder(order, seats, performance, 'purchase');
}
createdOrder!: Bestellung;
createdTickets!: Eintrittskarte[];
sendToBackend(order: Bestellung, seats: Sitzplatz[], performance: Vorstellung) {
submitOrder(order: Bestellung, seats: Sitzplatz[], performance: Vorstellung, mode: SubmissionMode) {
this.httpService.addOrder(order).pipe(
tap(createdOrder => {
this.createdOrder = createdOrder;
// Order erstellen
switchMap(createdOrder => {
const ticketCreations = seats.map(seat => {
// Tickets parallel erstellen
const ticketObservables = seats.map(seat => {
const ticket = this.generateNewTicketObject(performance, seat, createdOrder);
return this.httpService.addTicket(ticket);
});
Promise.all(ticketCreations.map(obs => obs.toPromise()))
.then(async createdTickets => {
this.createdTickets = createdTickets.filter(
(ticket): ticket is Eintrittskarte => ticket !== undefined
);
// Warten bis alles fertig sind
return forkJoin(ticketObservables).pipe(
tap(createdTickets => {
// Success Handling
if (mode === 'reservation') {
this.orderState.set({
status: 'reservation-success',
order: createdOrder
});
} else {
this.orderState.set({
status: 'purchase-success',
tickets: createdTickets
});
}
this.successful = true;
this.selectedSeatsService.setCommitedTrue();
this.selectedSeatsService.commit();
this.loadingService.hide();
this.confetti({
particleCount: 100,
angle: 0,
spread: 180,
origin: { x: -0.1, y: 0.75 }
});
this.confetti({
particleCount: 100,
angle: 180,
spread: 180,
origin: { x: 1.1, y: 0.75 }
});
this.showConfetti();
})
.catch(err => {
this.loadingService.showError(err);
this.successful = false;
console.error('Fehler beim Anlegen der Eintrittskarten', err);
});
);
}),
catchError(err => {
// Error handling
console.error('Fehler beim Anlegen der Bestellung/Tickets:', err);
this.loadingService.showError(err);
this.successful = false;
console.error('Fehler beim Anlegen der Bestellung', err);
return [];
if (mode === 'reservation') {
this.orderState.set({ status: 'reservation-error', error: err });
} else {
this.orderState.set({ status: 'purchase-error', error: err });
}
return EMPTY;
}),
finalize(() => {
this.enableInputs();
})
this.enableForms();
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
private showConfetti() {
this.confetti({
particleCount: 100,
angle: 0,
spread: 180,
origin: { x: -0.1, y: 0.75 }
});
this.confetti({
particleCount: 100,
angle: 180,
spread: 180,
origin: { x: 1.1, y: 0.75 }
});
}
private generateCode(length: number = 6): string {
const chars = "ABCDEFGHJKLMNPQRSUVWXYZ23456789";
@@ -206,7 +242,7 @@ export class OrderComponent {
private generateNewOrderObject(mail: string, isBooked: boolean): Bestellung {
return{
id: 0, // Wird durch Backend gesetzt
id: 0, // Wird durch das Backend gesetzt
mail: mail,
code: this.generateCode(length=6),
reserved: new Date(),
@@ -217,7 +253,7 @@ export class OrderComponent {
private generateNewTicketObject(show: Vorstellung, seat: Sitzplatz, order: Bestellung): Eintrittskarte {
return {
id: 0, // Wird durch Backend gesetzt
id: 0, // Wird durch das Backend gesetzt
code: 'T' + this.generateCode(length=7),
show: show,
seat: seat,
@@ -225,16 +261,17 @@ export class OrderComponent {
};
}
private disableInputs() {
private disableForms(): void {
this.dataForm.disable();
this.paymentForm.disable();
this.isSubmitting = true;
}
private enableInputs() {
private enableForms(): void {
this.dataForm.enable();
this.paymentForm.enable();
this.isSubmitting = false;
}
getPriceDisplay(price: number): string {
return `${(price / 100).toFixed(2)}`;
}
}