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:
@@ -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,
|
||||||
|
|||||||
@@ -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},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
11
src/app/cancellation-failed/cancellation-failed.component.ts
Normal file
11
src/app/cancellation-failed/cancellation-failed.component.ts
Normal 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 {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>();
|
||||||
|
}
|
||||||
11
src/app/conversion-failed/conversion-failed.component.html
Normal file
11
src/app/conversion-failed/conversion-failed.component.html
Normal 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>
|
||||||
|
|
||||||
11
src/app/conversion-failed/conversion-failed.component.ts
Normal file
11
src/app/conversion-failed/conversion-failed.component.ts
Normal 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 {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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} */
|
||||||
|
|||||||
@@ -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(() =>
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
</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>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
</div>
|
</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">
|
||||||
|
|
||||||
|
@if (isResuming()) {
|
||||||
|
<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">
|
<button mat-stroked-button color="primary" matStepperPrevious type="button" [disabled]="isSubmitting()" class="w-1/3">
|
||||||
Zurück
|
Zurück
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
if (this.isResuming()) {
|
||||||
|
const order = this.existingOrder()!;
|
||||||
|
order.booked = new Date();
|
||||||
|
this.convertOrder(order, this.existingTickets()!);
|
||||||
|
} else {
|
||||||
const order = this.generateNewOrderObject(this.dataForm.value.email, true);
|
const order = this.generateNewOrderObject(this.dataForm.value.email, true);
|
||||||
const seats = this.selectedSeatsService.selectedSeats();
|
const seats = this.selectedSeatsService.selectedSeats();
|
||||||
const performance = this.performance()!;
|
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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() }"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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';
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
if (this.orderCode) {
|
||||||
|
// Checkout fortsetzen
|
||||||
|
this.isResuming = true;
|
||||||
|
this.loadExistingOrder(this.orderCode);
|
||||||
|
|
||||||
|
} else if (this.showId) {
|
||||||
|
// Neuer Checkout
|
||||||
|
this.isResuming = false;
|
||||||
|
|
||||||
this.selectedSeatService.clearSelection();
|
this.selectedSeatService.clearSelection();
|
||||||
this.selectedSeatService.setSeatSelectable(true);
|
this.selectedSeatService.setSeatSelectable(true);
|
||||||
|
|
||||||
this.setupActivityTracking();
|
this.setupActivityTracking();
|
||||||
this.startAutoRefresh();
|
this.startAutoRefresh();
|
||||||
this.startInactivityCheck();
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user