Add cancellation and conversion error/success flows

Introduces new components for cancellation and conversion error/success states, updates routing and UI to support resuming and cancelling orders, and refactors seat selection and order handling logic to accommodate these new flows. Also improves seat selection feedback and error handling throughout the checkout process.
This commit is contained in:
2025-11-20 23:19:16 +01:00
parent 53bbef417b
commit 3bc5b9cd3a
21 changed files with 365 additions and 93 deletions

View File

@@ -65,6 +65,9 @@ import { TicketSmallComponent } from './ticket-small/ticket-small.component';
import { TicketListComponent } from './ticket-list/ticket-list.component'; import { TicketListComponent } from './ticket-list/ticket-list.component';
import { ZoomWarningComponent } from './zoom-warning/zoom-warning.component'; import { ZoomWarningComponent } from './zoom-warning/zoom-warning.component';
import { SelectionConflictInfoComponent } from './selection-conflict-info/selection-conflict-info.component'; import { SelectionConflictInfoComponent } from './selection-conflict-info/selection-conflict-info.component';
import { CancellationSuccessComponent } from './cancellation-success/cancellation-success.component';
import { CancellationFailedComponent } from './cancellation-failed/cancellation-failed.component';
import { ConversionFailedComponent } from './conversion-failed/conversion-failed.component';
@NgModule({ @NgModule({
@@ -109,6 +112,9 @@ import { SelectionConflictInfoComponent } from './selection-conflict-info/select
TicketListComponent, TicketListComponent,
ZoomWarningComponent, ZoomWarningComponent,
SelectionConflictInfoComponent, SelectionConflictInfoComponent,
CancellationSuccessComponent,
CancellationFailedComponent,
ConversionFailedComponent,
], ],
imports: [ imports: [
AppRoutingModule, AppRoutingModule,

View File

@@ -27,7 +27,8 @@ const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
data: { roles: ['admin'] }, // Array von erlaubten Rollen. Derzeit gäbe es 'admin' und 'employee' data: { roles: ['admin'] }, // Array von erlaubten Rollen. Derzeit gäbe es 'admin' und 'employee'
}, },
{ path: 'performance/:performanceId/checkout', component: TheaterOverlayComponent}, { path: 'checkout/performance/:performanceId', component: TheaterOverlayComponent},
{ path: 'checkout/order/:orderId', component: TheaterOverlayComponent},
], ],
}, },

View File

@@ -0,0 +1,11 @@
<div class="bg-red-100 text-red-500 rounded-md shadow-sm w-full h-fit p-6 py-8 items-center justify-center flex flex-col space-y-2">
<mat-icon class="material-symbols-outlined mb-5" style="font-size: 50px; width: 50px; height: 50px">
warning
</mat-icon>
<h1 class="text-xl font-bold">Stornierung fehlgeschlagen!</h1>
<p class="text-center">Leider konnten Ihre Sitzplätze nicht storniert werden. Möglicherweise wurden die Tickets bereits bezahlt oder storniert.</p>
<button mat-button matButton="filled" class="error-button mt-4 w-80">Erneut versuchen</button>
<button routerLink="/order" mat-button matButton="outlined" color="accent" class="error-button w-80 mt-1">Zurück zur Codeeingabe</button>
</div>

View File

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

View File

@@ -0,0 +1,11 @@
<div class="bg-green-200 rounded-md shadow-sm w-full h-fit p-6 py-8 items-center justify-center flex flex-col space-y-2">
<mat-icon class="material-symbols-outlined mb-5" style="font-size: 50px; width: 50px; height: 50px">
task_alt
</mat-icon>
<h1 class="text-xl font-bold">Stornierung erfolgreich!</h1>
<p class="text-center">Ihre Sitzplätze wurden erfolgreich storniert und stehen wieder zur Buchung zur Verfügnug.</p>
<button routerLink="/schedule" mat-button matButton="filled" color="accent" class="success-button w-80 mt-4">Zur Programmauswahl</button>
<button routerLink="/checkout/performance/{{performanceId()}}" mat-button matButton="outlined" class="success-button w-80 mt-1">Neue Tickets kaufen</button>
</div>

View File

@@ -0,0 +1,11 @@
import { Component, input } from '@angular/core';
@Component({
selector: 'app-cancellation-success',
standalone: false,
templateUrl: './cancellation-success.component.html',
styleUrl: './cancellation-success.component.css',
})
export class CancellationSuccessComponent {
performanceId = input.required<number>();
}

View File

@@ -0,0 +1,11 @@
<div class="bg-red-100 text-red-500 rounded-md shadow-sm w-full h-fit p-6 py-8 items-center justify-center flex flex-col space-y-2">
<mat-icon class="material-symbols-outlined mb-5" style="font-size: 50px; width: 50px; height: 50px">
warning
</mat-icon>
<h1 class="text-xl font-bold">Kauf fehlgeschlagen!</h1>
<p class="text-center">Leider konnten Ihre Sitzplätze nicht bezahlt werden. Möglicherweise wurden die Tickets bereits storniert.</p>
<button mat-button matButton="filled" class="error-button mt-4 w-80">Erneut versuchen</button>
<button routerLink="/order" mat-button matButton="outlined" color="accent" class="error-button w-80 mt-1">Zurück zur Codeeingabe</button>
</div>

View File

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

View File

@@ -33,9 +33,9 @@ export class HttpService {
return this.http.post<Bestellung>(`${this.baseUrl}bestellung`, order); return this.http.post<Bestellung>(`${this.baseUrl}bestellung`, order);
} }
/* PUT /api/bestellung/{id} */ /* PUT /api/bestellung */
updateOrder(id: number, order: Partial<Bestellung>): Observable<Bestellung> { updateOrder(order: Partial<Bestellung>): Observable<Bestellung> {
return this.http.put<Bestellung>(`${this.baseUrl}bestellung/${id}`, order); return this.http.put<Bestellung>(`${this.baseUrl}bestellung`, order);
} }
/* DELETE /api/bestellung/{id} */ /* DELETE /api/bestellung/{id} */

View File

@@ -15,7 +15,7 @@ export class MoviePerformanceComponent implements OnInit {
route: string = ''; route: string = '';
ngOnInit() { ngOnInit() {
this.route = `../performance/${this.id()}/checkout`; this.route = `../checkout/performance/${this.id()}`;
} }
startTime = computed(() => startTime = computed(() =>

View File

@@ -27,47 +27,47 @@
</div> </div>
} }
<mat-stepper orientation="horizontal" linear="true" [disableRipple]="true" (selectionChange)="onStepChange($event)" #stepper> <mat-stepper orientation="horizontal" linear="true" [selectedIndex]="isResuming()? 2 : 0" [disableRipple]="true" (selectionChange)="onStepChange($event)" #stepper>
<mat-step> <mat-step [editable]="!isResuming()">
<ng-template matStepLabel>Warenkorb</ng-template> <ng-template matStepLabel>Warenkorb</ng-template>
<div class="performance-info-space"></div> <div class="performance-info-space"></div>
@if (selectedSeatsService.hadConflict()) { @if (selectedSeatsService.hadConflict()) {
<app-selection-conflict-info></app-selection-conflict-info> <app-selection-conflict-info></app-selection-conflict-info>
}
<!-- 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>
<!-- Seat-Selection Overview --> <mat-divider></mat-divider>
<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-divider></mat-divider> <!-- 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>
<!-- Total Price --> <!-- Buttons -->
<div class="flex justify-between p-2 mt-1 items-baseline"> <div class="flex space-x-5 mt-10">
<p class="font-semibold text-lg"> <button mat-button matButton="outlined" matStepperNext class="w-1/2" [disabled]="totalSeats()==0" (click)="reservationClicked()">Reservieren</button>
Tickets gesamt: <button mat-button matButton="filled" matStepperNext class="w-1/2" [disabled]="totalSeats()==0" (click)="purchaseClicked()">Kaufen</button>
</p> </div>
<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>
<mat-step [stepControl]="dataForm"> <mat-step [editable]="!isResuming" [stepControl]="dataForm">
<form [formGroup]="dataForm"> <form [formGroup]="dataForm">
<ng-template matStepLabel>Anschrift</ng-template> <ng-template matStepLabel>Anschrift</ng-template>
@@ -129,6 +129,18 @@
<div class="h-4"></div> <div class="h-4"></div>
<app-purchase-failed></app-purchase-failed> <app-purchase-failed></app-purchase-failed>
} }
@else if (isConversionError() && !isSubmitting()) {
<div class="h-4"></div>
<app-conversion-failed></app-conversion-failed>
}
@else if (isCancellationSuccess() && !isSubmitting() && performance()) {
<div class="h-4"></div>
<app-cancellation-success [performanceId]="performance()!.id"></app-cancellation-success>
}
@else if (isCancellationError() && !isSubmitting()) {
<div class="h-4"></div>
<app-cancellation-failed></app-cancellation-failed>
}
@else { @else {
<!-- Card Number --> <!-- Card Number -->
@@ -183,9 +195,17 @@
<!-- Buttons --> <!-- Buttons -->
<div class="flex space-x-4 mt-8"> <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 @if (isResuming()) {
</button> <button mat-stroked-button color="primary" type="button" [disabled]="isSubmitting()" class="w-1/3" (click)="cancelReservation()">
Stornieren
</button>
} @else {
<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"> <button mat-flat-button color="accent" matStepperNext type="submit" [disabled]="isSubmitting()" class="w-2/3">
{{ getPriceDisplay(totalPrice()) }} jetzt bezahlen {{ getPriceDisplay(totalPrice()) }} jetzt bezahlen
</button> </button>

View File

@@ -15,7 +15,10 @@ type OrderState =
| { status: 'reservation-success'; order: Bestellung } | { status: 'reservation-success'; order: Bestellung }
| { status: 'reservation-error'; error: any } | { status: 'reservation-error'; error: any }
| { status: 'purchase-success'; tickets: Eintrittskarte[] } | { status: 'purchase-success'; tickets: Eintrittskarte[] }
| { status: 'purchase-error'; error: any }; | { status: 'purchase-error'; error: any }
| { status: 'conversion-error'; error: any }
| { status: 'cancellation-success'; }
| { status: 'cancellation-error'; error: any };
type SubmissionMode = 'reservation' | 'purchase'; type SubmissionMode = 'reservation' | 'purchase';
@@ -36,6 +39,9 @@ export class OrderComponent {
performance = input<Vorstellung>(); performance = input<Vorstellung>();
seatCategories = input.required<Sitzkategorie[]>(); seatCategories = input.required<Sitzkategorie[]>();
existingOrder = input<Bestellung>();
existingTickets = input<Eintrittskarte[]>();
stepChanged = output<number>(); stepChanged = output<number>();
paymentForm!: FormGroup; paymentForm!: FormGroup;
@@ -52,6 +58,16 @@ export class OrderComponent {
isSubmitting = computed(() => this.orderState().status === 'submitting'); isSubmitting = computed(() => this.orderState().status === 'submitting');
isResuming = computed(() => {
const order = this.existingOrder();
const tickets = this.existingTickets();
if (!order || !tickets || tickets.length === 0) {
return false;
}
return true;
});
secondPhaseButtonText = computed(() => { secondPhaseButtonText = computed(() => {
const mode = this.submissionMode(); const mode = this.submissionMode();
if (!mode) return 'Loading...'; if (!mode) return 'Loading...';
@@ -70,6 +86,10 @@ export class OrderComponent {
this.orderState().status === 'purchase-success' this.orderState().status === 'purchase-success'
); );
isCancellationSuccess = computed(() =>
this.orderState().status === 'cancellation-success'
);
isReservationError = computed(() => isReservationError = computed(() =>
this.orderState().status === 'reservation-error' this.orderState().status === 'reservation-error'
); );
@@ -78,6 +98,14 @@ export class OrderComponent {
this.orderState().status === 'purchase-error' this.orderState().status === 'purchase-error'
); );
isConversionError = computed(() =>
this.orderState().status === 'conversion-error'
);
isCancellationError = computed(() =>
this.orderState().status === 'cancellation-error'
);
createdOrder = computed(() => { createdOrder = computed(() => {
const state = this.orderState(); const state = this.orderState();
return state.status === 'reservation-success' ? state.order : null; return state.status === 'reservation-success' ? state.order : null;
@@ -138,7 +166,6 @@ export class OrderComponent {
makeReservation() { makeReservation() {
this.orderState.set({ status: 'submitting' }); this.orderState.set({ status: 'submitting' });
this.loadingService.show();
this.disableForms(); this.disableForms();
const order = this.generateNewOrderObject(this.dataForm.value.email, false); const order = this.generateNewOrderObject(this.dataForm.value.email, false);
@@ -157,15 +184,51 @@ export class OrderComponent {
this.loadingService.show(); this.loadingService.show();
this.disableForms(); this.disableForms();
const order = this.generateNewOrderObject(this.dataForm.value.email, true); if (this.isResuming()) {
const seats = this.selectedSeatsService.selectedSeats(); const order = this.existingOrder()!;
const performance = this.performance()!; order.booked = new Date();
this.convertOrder(order, this.existingTickets()!);
} else {
const order = this.generateNewOrderObject(this.dataForm.value.email, true);
const seats = this.selectedSeatsService.selectedSeats();
const performance = this.performance()!;
this.submitOrder(order, seats, performance, 'purchase'); this.submitOrder(order, seats, performance, 'purchase');
}
} }
private convertOrder(order: Bestellung, tickets: Eintrittskarte[]) {
this.loadingService.show();
this.httpService.updateOrder(order).pipe(
tap(() => {
// Success Handling
this.orderState.set({
status: 'purchase-success',
tickets: tickets
});
submitOrder(order: Bestellung, seats: Sitzplatz[], performance: Vorstellung, mode: SubmissionMode) { this.selectedSeatsService.commit();
this.loadingService.hide();
this.showConfetti();
}),
catchError(err => {
// Error handling
this.selectedSeatsService.error();
this.loadingService.showError(err);
console.error('Fehler bei der Umwandlung der Bestellung:', err);
this.orderState.set({status: 'conversion-error', error: err});
return EMPTY;
}),
finalize(() => {
this.enableForms();
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
private submitOrder(order: Bestellung, seats: Sitzplatz[], performance: Vorstellung, mode: SubmissionMode) {
this.loadingService.hide();
// Tickets anlegen // Tickets anlegen
const tickets = seats.map(seat => { const tickets = seats.map(seat => {
@@ -194,8 +257,9 @@ export class OrderComponent {
}), }),
catchError(err => { catchError(err => {
// Error handling // Error handling
console.error('Fehler beim Anlegen der Bestellung/Tickets:', err); this.selectedSeatsService.error();
this.loadingService.showError(err); this.loadingService.showError(err);
console.error('Fehler beim Anlegen der Bestellung/Tickets:', err);
if (mode === 'reservation') { if (mode === 'reservation') {
this.orderState.set({ status: 'reservation-error', error: err }); this.orderState.set({ status: 'reservation-error', error: err });
@@ -260,6 +324,34 @@ export class OrderComponent {
}; };
} }
cancelReservation() {
const order = this.existingOrder()!;
order.cancelled = new Date();
this.loadingService.show();
this.httpService.updateOrder(order).pipe(
tap(() => {
// Success Handling
this.orderState.set({
status: 'cancellation-success'
});
this.selectedSeatsService.cancel();
this.loadingService.hide();
}),
catchError(err => {
// Error handling
this.selectedSeatsService.error();
this.loadingService.showError(err);
console.error('Fehler bei der Bezahlung der Bestellung:', err);
this.orderState.set({status: 'cancellation-error', error: err});
return EMPTY;
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
private disableForms(): void { private disableForms(): void {
this.dataForm.disable(); this.dataForm.disable();
this.paymentForm.disable(); this.paymentForm.disable();

View File

@@ -6,7 +6,7 @@
<strong>{{ order().code }}</strong> <strong>{{ order().code }}</strong>
</div> </div>
<button [disabled]="true" mat-button matButton="filled" color="accent" class="success-button mt-2 w-80">Tickets jetzt online bezahlen</button> <button routerLink="/checkout/order/{{ order().code }}" mat-button matButton="filled" color="accent" class="success-button mt-2 w-80">Tickets jetzt online bezahlen</button>
<button routerLink="/schedule" mat-button matButton="outlined" class="success-button mb-4 w-80 mt-1">Zurück zur Programmauswahl</button> <button routerLink="/schedule" mat-button matButton="outlined" class="success-button mb-4 w-80 mt-1">Zurück zur Programmauswahl</button>
<div class="text-green-500 cursor-pointer w-fit mt-2" (click)="cancelReservation()"> <div class="text-green-500 cursor-pointer w-fit mt-2" (click)="cancelReservation()">
Reservierung stornieren Reservierung stornieren

View File

@@ -1,4 +1,4 @@
<button (click)="updateSelectedSeats(this.seat())" [disabled]="state() == TheaterSeatState.BOOKED || state() == TheaterSeatState.RESERVED || !seatService.getSeatIsSelectable()" class="mx-0.5"> <button (click)="updateSelectedSeats(this.seat())" [disabled]="!isClickable()" class="mx-0.5">
<mat-icon <mat-icon
[class]="isHoverable()? 'hover:opacity-50' : ''" [class]="isHoverable()? 'hover:opacity-50' : ''"
[ngStyle]="{color : getSeatStateColor() }" [ngStyle]="{color : getSeatStateColor() }"

View File

@@ -1,7 +1,7 @@
import {Component, inject, input} from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import {TheaterSeatState} from '../model/theater-seat-state.model'; import { TheaterSeatState } from '../model/theater-seat-state.model';
import {Sitzplatz} from '@infinimotion/model-frontend'; import { Sitzplatz } from '@infinimotion/model-frontend';
import {SelectedSeatsService} from '../selected-seats.service'; import { SelectedSeatsService } from '../selected-seats.service';
@Component({ @Component({
selector: 'app-seat', selector: 'app-seat',
@@ -13,14 +13,17 @@ export class SeatComponent{
seat = input.required<Sitzplatz>(); seat = input.required<Sitzplatz>();
state = input.required<TheaterSeatState>() state = input.required<TheaterSeatState>()
selected: boolean = false; selected = computed(() => this.seatService.isSeatSelected(this.seat().id));
protected seatService = inject(SelectedSeatsService) protected seatService = inject(SelectedSeatsService)
protected readonly TheaterSeatState = TheaterSeatState; protected readonly TheaterSeatState = TheaterSeatState;
getSeatStateColor(): string { getSeatStateColor(): string {
if (this.isSelectedAndAvaliable()) { if (this.selected()) {
return this.seatService.committed()? '#00c951' : '#6366f1'; if(this.seatService.errored()) return '#f01d05';
if(this.seatService.committed()) return '#00c951';
if(this.seatService.cancelled()) return '#c0c0c0';
return '#6366f1';
} }
if (!this.seatService.getSeatIsSelectable()) { if (!this.seatService.getSeatIsSelectable()) {
@@ -50,16 +53,18 @@ export class SeatComponent{
} }
updateSelectedSeats(selectedSeat: Sitzplatz) : void { updateSelectedSeats(selectedSeat: Sitzplatz) : void {
if(!this.selected){ if (!this.selected()){
this.seatService.pushSelectedSeat(selectedSeat); this.seatService.pushSelectedSeat(selectedSeat);
} else { } else {
this.seatService.removeSelectedSeat(selectedSeat); this.seatService.removeSelectedSeat(selectedSeat);
} }
this.selected = !this.selected;
} }
isSelectedAndAvaliable(): boolean { isClickable(): boolean {
return this.selected && this.state() == TheaterSeatState.AVAILABLE; if (this.state() === TheaterSeatState.BOOKED ||
this.state() === TheaterSeatState.RESERVED) {
return false;
}
return this.seatService.getSeatIsSelectable();
} }
} }

View File

@@ -9,12 +9,16 @@ export class SelectedSeatsService {
private selectedSeatsSignal = signal<Sitzplatz[]>([]); private selectedSeatsSignal = signal<Sitzplatz[]>([]);
private seatIsSelectableSignal = signal(true); private seatIsSelectableSignal = signal(true);
private committedSignal = signal(false); private committedSignal = signal(false);
private erroredSignal = signal(false);
private cancelledSignal = signal(false);
private debugSignal = signal(false); private debugSignal = signal(false);
private hadConflictSignal = signal(false); private hadConflictSignal = signal(false);
readonly selectedSeats = this.selectedSeatsSignal.asReadonly(); readonly selectedSeats = this.selectedSeatsSignal.asReadonly();
readonly seatIsSelectable = this.seatIsSelectableSignal.asReadonly(); readonly seatIsSelectable = this.seatIsSelectableSignal.asReadonly();
readonly committed = this.committedSignal.asReadonly(); readonly committed = this.committedSignal.asReadonly();
readonly errored = this.erroredSignal.asReadonly();
readonly cancelled = this.cancelledSignal.asReadonly();
readonly debug = this.debugSignal.asReadonly(); readonly debug = this.debugSignal.asReadonly();
readonly hadConflict = this.hadConflictSignal.asReadonly(); readonly hadConflict = this.hadConflictSignal.asReadonly();
@@ -38,6 +42,8 @@ export class SelectedSeatsService {
clearSelection(): void { clearSelection(): void {
this.selectedSeatsSignal.set([]); this.selectedSeatsSignal.set([]);
this.committedSignal.set(false); this.committedSignal.set(false);
this.cancelledSignal.set(false);
this.erroredSignal.set(false);
this.hadConflictSignal.set(false); this.hadConflictSignal.set(false);
} }
@@ -49,6 +55,8 @@ export class SelectedSeatsService {
this.seatIsSelectableSignal.set(selectable); this.seatIsSelectableSignal.set(selectable);
if (selectable) { if (selectable) {
this.committedSignal.set(false); this.committedSignal.set(false);
this.cancelledSignal.set(false);
this.erroredSignal.set(false);
} }
} }
@@ -56,6 +64,14 @@ export class SelectedSeatsService {
this.committedSignal.set(true); this.committedSignal.set(true);
} }
error(): void {
this.erroredSignal.set(true);
}
cancel(): void {
this.cancelledSignal.set(true);
}
toggleDebug(): void { toggleDebug(): void {
this.debugSignal.update(debug => !debug); this.debugSignal.update(debug => !debug);
} }

View File

@@ -4,7 +4,7 @@
<div class="w-7/10 p-10 h-fit"> <div class="w-7/10 p-10 h-fit">
<div> <div>
@if (!performance && (loading.loading$ | async)){ @if (!performance() && (loading.loading$ | async)){
<div class="w-full h-full flex items-center justify-center mt-70"> <div class="w-full h-full flex items-center justify-center mt-70">
<mat-progress-spinner <mat-progress-spinner
mode="indeterminate" mode="indeterminate"
@@ -18,6 +18,6 @@
</div> </div>
</div> </div>
<app-order (stepChanged)="setStepTwoOrHigher($event >= 1)" class="mt-10 mr-30 w-3/10" [performance]="performance" [seatCategories]="seatCategories"></app-order> <app-order (stepChanged)="setStepTwoOrHigher($event >= 1)" class="mt-10 mr-30 w-3/10" [performance]="performance()" [seatCategories]="seatCategories" [existingOrder]="isResuming? order : undefined" [existingTickets]="isResuming? tickets : undefined"></app-order>
</div> </div>

View File

@@ -1,10 +1,10 @@
import { Component, DestroyRef, inject, OnDestroy, OnInit, signal } from '@angular/core'; import { Component, DestroyRef, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { HttpService } from '../http.service'; import { HttpService } from '../http.service';
import { LoadingService } from '../loading.service'; import { LoadingService } from '../loading.service';
import { catchError, filter, forkJoin, fromEvent, interval, merge, of, startWith, switchMap, tap } from 'rxjs'; import { catchError, filter, finalize, forkJoin, from, fromEvent, interval, merge, of, startWith, switchMap, tap } from 'rxjs';
import { Sitzkategorie, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend'; import { Bestellung, Eintrittskarte, Sitzkategorie, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend';
import { TheaterSeatState } from '../model/theater-seat-state.model'; import { TheaterSeatState } from '../model/theater-seat-state.model';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { SelectedSeatsService } from '../selected-seats.service'; import { SelectedSeatsService } from '../selected-seats.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatSnackBar, MatSnackBarRef, TextOnlySnackBar } from '@angular/material/snack-bar'; import { MatSnackBar, MatSnackBarRef, TextOnlySnackBar } from '@angular/material/snack-bar';
@@ -21,19 +21,25 @@ const INACTIVITY_TIMEOUT_MS = 2 * 60 * 1000;
export class TheaterOverlayComponent implements OnInit, OnDestroy { export class TheaterOverlayComponent implements OnInit, OnDestroy {
private http = inject(HttpService); private http = inject(HttpService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
private selectedSeatService = inject(SelectedSeatsService); private selectedSeatService = inject(SelectedSeatsService);
readonly loading = inject(LoadingService); readonly loading = inject(LoadingService);
showId!: number; showId?: number | null;
orderId?: string; orderId?: number;
performance: Vorstellung | undefined; orderCode?: string | null;
isResuming = false;
tickets: Eintrittskarte[] | undefined;
order: Bestellung | undefined;
blockedSeats: Sitzplatz[] | undefined; blockedSeats: Sitzplatz[] | undefined;
seatsPerRow = signal<{ seat: Sitzplatz | null, state: TheaterSeatState | null }[][]>([]);
seatCategories: Sitzkategorie[] = []; seatCategories: Sitzkategorie[] = [];
snackBarRef: MatSnackBarRef<TextOnlySnackBar> | undefined snackBarRef: MatSnackBarRef<TextOnlySnackBar> | undefined
performance = signal<Vorstellung | undefined>(undefined);
seatsPerRow = signal<{ seat: Sitzplatz | null, state: TheaterSeatState | null }[][]>([]);
private isPollingEnabled = signal(true); private isPollingEnabled = signal(true);
private isInitialLoad = signal(true); private isInitialLoad = signal(true);
private lastActivityTimestamp = signal(Date.now()); private lastActivityTimestamp = signal(Date.now());
@@ -45,13 +51,29 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
ngOnInit() { ngOnInit() {
this.showId = Number(this.route.snapshot.paramMap.get('performanceId')!); this.showId = Number(this.route.snapshot.paramMap.get('performanceId')!);
this.orderId = this.route.snapshot.queryParams['paramName']; this.orderCode = this.route.snapshot.paramMap.get('orderId');
this.selectedSeatService.clearSelection();
this.selectedSeatService.setSeatSelectable(true);
this.setupActivityTracking(); if (this.orderCode) {
this.startAutoRefresh(); // Checkout fortsetzen
this.startInactivityCheck(); this.isResuming = true;
this.loadExistingOrder(this.orderCode);
} else if (this.showId) {
// Neuer Checkout
this.isResuming = false;
this.selectedSeatService.clearSelection();
this.selectedSeatService.setSeatSelectable(true);
this.setupActivityTracking();
this.startAutoRefresh();
this.startInactivityCheck();
} else {
// Fallback
console.error('Ungültige Checkout-Route');
this.router.navigate(['/performances']);
}
} }
ngOnDestroy() { ngOnDestroy() {
@@ -156,18 +178,20 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
} }
private loadPerformanceAndSeats() { private loadPerformanceAndSeats() {
if (this.isInitialLoad()) { const isInitial = this.isInitialLoad();
if (isInitial) {
this.loading.show(); this.loading.show();
} }
this.isRequestInProgress.set(true); this.isRequestInProgress.set(true);
return forkJoin({ return forkJoin({
performance: this.http.getPerformaceById(this.showId), performance: this.http.getPerformaceById(this.showId!),
seats: this.http.getSeatsByShowId(this.showId) seats: this.http.getSeatsByShowId(this.showId!)
}).pipe( }).pipe(
tap(({ performance, seats }) => { tap(({ performance, seats }) => {
this.performance = performance; this.performance.set(performance);
if (this.blockedSeats && !this.equalSeats(this.blockedSeats, seats.reserved)) { if (this.blockedSeats && !this.equalSeats(this.blockedSeats, seats.reserved)) {
console.info('[TheaterOverlay] External booking detected. Checking for conflicts.'); console.info('[TheaterOverlay] External booking detected. Checking for conflicts.');
@@ -181,36 +205,78 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
this.selectedSeatService.selectedSeats this.selectedSeatService.selectedSeats
} }
this.blockedSeats = seats.reserved;
this.blockedSeats = seats.reserved;
this.seatsPerRow.set(this.converter(seats)); this.seatsPerRow.set(this.converter(seats));
if (this.isInitialLoad()) { if (isInitial) {
this.loading.hide(); this.loading.hide();
this.isInitialLoad.set(false);
} }
this.isRequestInProgress.set(false);
}), }),
catchError(err => { catchError(err => {
if (this.isInitialLoad()) { if (isInitial) {
this.loading.showError(err); this.loading.showError(err);
} else { } else {
console.warn('Fehler beim Aktualisieren der Sitze:', err); console.warn('Fehler beim Aktualisieren der Sitze:', err);
} }
if (this.isInitialLoad()) { return of({ performance: null, seats: { seats: [], reserved: [], booked: [] } });
this.loading.hide(); }),
finalize(() => {
if (isInitial) {
this.isInitialLoad.set(false); this.isInitialLoad.set(false);
} }
this.isRequestInProgress.set(false); this.isRequestInProgress.set(false);
return of({ performance: null, seats: { seats: [], reserved: [], booked: [] } });
}) })
); );
} }
private loadExistingOrder(orderCode: string) {
this.loading.show();
const ticketFilter = [`eq;order.code;string;${orderCode}`];
this.http.getTicketsByFilter(ticketFilter).pipe(
switchMap(tickets => {
this.tickets = tickets;
if (!tickets.length) {
return from(this.router.navigate(
['/checkout/order'],
{ queryParams: { error: 'invalid' } }
));
}
this.order = tickets[0].order;
if (this.order.booked || this.order.cancelled) {
return from(this.router.navigate(
['/checkout/order'],
{ queryParams: { error: 'completed' } }
));
}
this.showId = this.tickets[0].show.id;
this.selectedSeatService.clearSelection();
this.tickets.forEach(t => this.selectedSeatService.pushSelectedSeat(t.seat));
this.selectedSeatService.setSeatSelectable(false);
return this.loadPerformanceAndSeats();
}),
catchError(err => {
this.loading.hide();
console.error('Fehler beim Laden der Bestellung', err);
return from(this.router.navigate(
['/checkout/order'],
{ queryParams: { error: 'invalid' } }
));
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
private equalSeats(a: Sitzplatz[], b: Sitzplatz[]): boolean { private equalSeats(a: Sitzplatz[], b: Sitzplatz[]): boolean {
if (a === b) return true; if (a === b) return true;
if (a == null || b == null) return false; if (a == null || b == null) return false;