Merge branch 'main' of git.infinimotion.de:infinimotion/frontend

This commit is contained in:
Marcel-Anker
2025-11-22 11:24:09 +01:00
46 changed files with 1002 additions and 229 deletions

View File

@@ -57,7 +57,6 @@ import { MovieImportNoSearchResultComponent } from './movie-import-no-search-res
import { MovieImportSearchInfoComponent } from './movie-import-search-info/movie-import-search-info.component';
import { LoginDialog } from './login/login.dialog';
import { PerformanceInfoComponent } from './performance-info/performance-info.component';
import { ShoppingCartComponent } from './shopping-cart/shopping-cart.component';
import { OrderComponent } from './order/order.component';
import { SeatSelectionComponent } from './seat-selection/seat-selection.component';
import { NoSeatsInHallComponent } from './no-seats-in-hall/no-seats-in-hall.component';
@@ -69,6 +68,12 @@ import { TicketSmallComponent } from './ticket-small/ticket-small.component';
import { TicketListComponent } from './ticket-list/ticket-list.component';
import { StatisticsComponent } from './statistics/statistics.component';
import { ZoomWarningComponent } from './zoom-warning/zoom-warning.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';
import { PayForOrderComponent } from './pay-for-order/pay-for-order.component';
import { CancelOrderDialog } from './cancel-order/cancel-order.dialog';
import { PricelistComponent } from './pricelist/pricelist.component';
@@ -104,7 +109,6 @@ import { PricelistComponent } from './pricelist/pricelist.component';
MovieImportSearchInfoComponent,
LoginDialog,
PerformanceInfoComponent,
ShoppingCartComponent,
OrderComponent,
SeatSelectionComponent,
NoSeatsInHallComponent,
@@ -116,6 +120,12 @@ import { PricelistComponent } from './pricelist/pricelist.component';
TicketListComponent,
StatisticsComponent,
ZoomWarningComponent,
SelectionConflictInfoComponent,
CancellationSuccessComponent,
CancellationFailedComponent,
ConversionFailedComponent,
PayForOrderComponent,
CancelOrderDialog,
PricelistComponent,
],
imports: [

View File

@@ -8,7 +8,8 @@ import { ScheduleComponent } from './schedule/schedule.component';
import { TheaterOverlayComponent} from './theater-overlay/theater-overlay.component';
import { MovieImporterComponent } from './movie-importer/movie-importer.component';
import { AuthGuard } from './auth.guard';
import {StatisticsComponent} from './statistics/statistics.component';
import { PayForOrderComponent } from './pay-for-order/pay-for-order.component';
import { StatisticsComponent } from './statistics/statistics.component';
import { PricelistComponent } from './pricelist/pricelist.component';
const routes: Routes = [
@@ -29,7 +30,9 @@ const routes: Routes = [
canActivate: [AuthGuard],
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},
{ path: 'checkout/order', component: PayForOrderComponent},
{
path: 'admin/statistics',
component: StatisticsComponent,

View File

@@ -0,0 +1,3 @@
button {
min-width: 100px;
}

View File

@@ -0,0 +1,10 @@
<h2 mat-dialog-title>Möchten Sie Ihre Bestellung wirklich stornieren?</h2>
<mat-dialog-content class="min-w-[400px]">
<p class="text-sm text-gray-600 mb-2">Nach der Stornierung verlieren Sie Ihr Reservierungsrecht und die Sitzplätze können von anderen Kunden in Anspruch genommen werden. Dieser Prozess kann nicht rückgängig gemacht werden.</p>
</mat-dialog-content>
<mat-dialog-actions class="justify-end gap-2">
<button mat-stroked-button color="warn" (click)="cancel()">Abbrechen</button>
<button mat-flat-button color="primary" (click)="submit()">Stornieren</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,22 @@
import { Component } from '@angular/core';
import { MatDialogRef } from '@angular/material/dialog';
@Component({
selector: 'app-cancel-order',
standalone: false,
templateUrl: './cancel-order.dialog.html',
styleUrl: './cancel-order.dialog.css',
})
export class CancelOrderDialog {
constructor(
private dialogRef: MatDialogRef<CancelOrderDialog>,
) {}
submit(): void {
this.dialogRef.close(true);
}
cancel(): void {
this.dialogRef.close(false);
}
}

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">{{ infoText }}</p>
<button mat-button type="button" matButton="filled" class="error-button mt-4 w-80" (click)="retry.emit()">Erneut versuchen</button>
<button routerLink="/order" type="button" mat-button matButton="outlined" color="accent" class="error-button w-80 mt-1">Zurück zur Codeeingabe</button>
</div>

View File

@@ -0,0 +1,21 @@
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-cancellation-failed',
standalone: false,
templateUrl: './cancellation-failed.component.html',
styleUrl: './cancellation-failed.component.css',
})
export class CancellationFailedComponent {
moreThanOne = input<boolean>(false);
retry = output<void>();
infoText!: string;
ngOnInit(): void {
this.infoText = this.moreThanOne()?
'Leider konnten Ihre Sitzplätze nicht storniert werden. Möglicherweise wurden die Tickets bereits bezahlt oder storniert.' :
'Leider konnte Ihr Sitzplatz nicht storniert werden. Möglicherweise wurde das Ticket bereits bezahlt oder storniert.';
}
}

View File

@@ -0,0 +1,11 @@
<div class="bg-indigo-100 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">{{ infoText }}</p>
<button routerLink="/schedule" type="button" mat-button matButton="filled" color="accent" class="w-80 mt-4">Zur Programmauswahl</button>
<button type="button" mat-button matButton="outlined" class="w-80 mt-1" (click)="navigate()">Neue Tickets kaufen</button>
</div>

View File

@@ -0,0 +1,27 @@
import { Component, inject, input } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-cancellation-success',
standalone: false,
templateUrl: './cancellation-success.component.html',
styleUrl: './cancellation-success.component.css',
})
export class CancellationSuccessComponent {
performanceId = input.required<number>();
moreThanOne = input<boolean>(false);
router = inject(Router);
infoText!: string;
ngOnInit(): void {
this.infoText = this.moreThanOne()?
'Ihre Sitzplätze wurden erfolgreich storniert und stehen wieder zur Buchung zur Verfügnug.' :
'Ihr Sitzplatz wurden erfolgreich storniert und steht wieder zur Buchung zur Verfügnug.';
}
navigate() {
window.location.href = `/checkout/performance/${this.performanceId()}`;
}
}

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">{{ infoText }}</p>
<button mat-button type="button" matButton="filled" class="error-button mt-4 w-80" (click)="retry.emit()">Erneut versuchen</button>
<button routerLink="/order" type="button" mat-button matButton="outlined" color="accent" class="error-button w-80 mt-1">Zurück zur Codeeingabe</button>
</div>

View File

@@ -0,0 +1,21 @@
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-conversion-failed',
standalone: false,
templateUrl: './conversion-failed.component.html',
styleUrl: './conversion-failed.component.css',
})
export class ConversionFailedComponent {
moreThanOne = input<boolean>(false);
retry = output<void>();
infoText!: string;
ngOnInit(): void {
this.infoText = this.moreThanOne()?
'Leider konnten Ihre Sitzplätze nicht bezahlt werden. Möglicherweise wurden die Tickets bereits storniert.' :
'Leider konnte Ihr Sitzplatz nicht bezahlt werden. Möglicherweise wurde das Ticket bereits storniert.';
}
}

View File

@@ -23,16 +23,6 @@ export class HttpService {
/* Bestellung APIs */
/* GET /api/bestellung/{id} */
getAllOrder(id: number): Observable<Bestellung[]> {
return this.http.get<Bestellung[]>(`${this.baseUrl}bestellung`);
}
/* GET /api/bestellung/{id} */
getOrderById(id: number): Observable<Bestellung> {
return this.http.get<Bestellung>(`${this.baseUrl}bestellung/${id}`);
}
/* POST /api/bestellung/filter */
getOrdersByFilter(filter: string[]): Observable<Bestellung[]> {
return this.http.post<Bestellung[]>(`${this.baseUrl}bestellung/filter`, filter);
@@ -43,17 +33,11 @@ export class HttpService {
return this.http.post<Bestellung>(`${this.baseUrl}bestellung`, order);
}
/* PUT /api/bestellung/{id} */
updateOrder(id: number, order: Partial<Bestellung>): Observable<Bestellung> {
return this.http.put<Bestellung>(`${this.baseUrl}bestellung/${id}`, order);
/* PUT /api/bestellung */
updateOrder(order: Partial<Bestellung>): Observable<Bestellung> {
return this.http.put<Bestellung>(`${this.baseUrl}bestellung`, order);
}
/* DELETE /api/bestellung/{id} */
deleteOrder(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}bestellung/${id}`);
}
/* POST /api/order-transaction/create */
saveAddOrder(req: {order:Bestellung, tickets:Eintrittskarte[]}): Observable<{order:Bestellung, tickets:Eintrittskarte[]}> {
return this.http.post<{order: Bestellung, tickets: Eintrittskarte[]}>(`${this.baseUrl}order-transaction/create`, req);
@@ -62,36 +46,11 @@ export class HttpService {
/* Eintrittskarte APIs */
/* GET /api/eintrittskarte/{id} */
getAllTickets(id: number): Observable<Eintrittskarte[]> {
return this.http.get<Eintrittskarte[]>(`${this.baseUrl}eintrittskarte`);
}
/* GET /api/eintrittskarte/{id} */
getTicketById(id: number): Observable<Eintrittskarte> {
return this.http.get<Eintrittskarte>(`${this.baseUrl}eintrittskarte/${id}`);
}
/* POST /api/eintrittskarte/filter */
getTicketsByFilter(filter: string[]): Observable<Eintrittskarte[]> {
return this.http.post<Eintrittskarte[]>(`${this.baseUrl}eintrittskarte/filter`, filter);
}
/* POST /api/eintrittskarte */
addTicket(order: Omit<Eintrittskarte, 'id'>): Observable<Eintrittskarte> {
return this.http.post<Eintrittskarte>(`${this.baseUrl}eintrittskarte`, order);
}
/* PUT /api/eintrittskarte/{id} */
updateTicket(id: number, order: Partial<Eintrittskarte>): Observable<Eintrittskarte> {
return this.http.put<Eintrittskarte>(`${this.baseUrl}eintrittskarte/${id}`, order);
}
/* DELETE /api/eintrittskarte/{id} */
deleteTicket(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}eintrittskarte/${id}`);
}
/* Kinosaal APIs */
@@ -100,34 +59,14 @@ export class HttpService {
return this.http.get<Kinosaal[]>(`${this.baseUrl}kinosaal`);
}
/* GET /api/kinosaal/{id} */
getKinosaalById(id: number): Observable<Kinosaal> {
return this.http.get<Kinosaal>(`${this.baseUrl}kinosaal/${id}`);
}
/* POST /api/kinosaal */
addKinosaal(kinosaal: Omit<Kinosaal, 'id'>): Observable<Kinosaal> {
return this.http.post<Kinosaal>(`${this.baseUrl}kinosaal`, kinosaal);
}
/* PUT /api/kinosaal/{id} */
updateKinosaal(id: number, kinosaal: Partial<Kinosaal>): Observable<Kinosaal> {
return this.http.put<Kinosaal>(`${this.baseUrl}kinosaal/${id}`, kinosaal);
}
/* DELETE /api/kinosaal/{id} */
deleteKinosaal(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}kinosaal/${id}`);
}
/* Vorstellung APIs */
/* GET /api/vorstellung */
getAllPerformaces(): Observable<Vorstellung[]> {
return this.http.get<Vorstellung[]>(`${this.baseUrl}vorstellung`);
}
/* GET /api/vorstellung/{id} */
getPerformaceById(id: number): Observable<Vorstellung> {
return this.http.get<Vorstellung>(`${this.baseUrl}vorstellung/${id}`);
@@ -138,21 +77,6 @@ export class HttpService {
return this.http.post<Vorstellung[]>(`${this.baseUrl}vorstellung/filter`, filter);
}
/* POST /api/vorstellung */
addPerformace(vorstellung: Omit<Vorstellung, 'id'>): Observable<Vorstellung> {
return this.http.post<Vorstellung>(`${this.baseUrl}vorstellung`, vorstellung);
}
/* PUT /api/vorstellung/{id} */
updatePerformace(id: number, vorstellung: Partial<Vorstellung>): Observable<Vorstellung> {
return this.http.put<Vorstellung>(`${this.baseUrl}vorstellung/${id}`, vorstellung);
}
/* DELETE /api/vorstellung/{id} */
deletePerformace(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}vorstellung/${id}`);
}
/* Film APIs */

View File

@@ -1,7 +1,7 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { BehaviorSubject } from 'rxjs';
import { MatSnackBar, MatSnackBarRef } from '@angular/material/snack-bar';
import { BehaviorSubject, Subscription } from 'rxjs';
@Injectable({
providedIn: 'root'
@@ -13,6 +13,9 @@ export class LoadingService {
public loading$ = this.loadingSubject.asObservable();
public error$ = this.errorSubject.asObservable();
private currentSnackBarRef?: MatSnackBarRef<any>;
private currentSubscription?: Subscription;
constructor(private snackBar: MatSnackBar) {}
show(): void {
@@ -23,6 +26,7 @@ export class LoadingService {
hide(): void {
this.loadingSubject.next(false);
this.errorSubject.next(false);
this.currentSnackBarRef?.dismiss();
}
showError(messageOrError?: string | HttpErrorResponse | any): void {
@@ -35,15 +39,22 @@ export class LoadingService {
const message = this.getErrorMessage(messageOrError);
const snackBarRef = this.snackBar.open(message, 'Schließen', {
if (this.currentSnackBarRef) {
this.currentSubscription?.unsubscribe();
this.currentSnackBarRef.dismiss();
}
this.currentSnackBarRef = this.snackBar.open(message, 'Schließen', {
duration: 0,
panelClass: ['error-snackbar'],
horizontalPosition: 'center',
verticalPosition: 'bottom'
});
snackBarRef.afterDismissed().subscribe(() => {
this.hide();
this.currentSubscription = this.currentSnackBarRef.afterDismissed().subscribe(() => {
if (!this.loadingSubject.value) {
this.hide();
}
});
}

View File

@@ -5,7 +5,7 @@
<div class="flex items-center space-x-4">
<mat-form-field class="w-full" subscriptSizing="dynamic">
<mat-label>Film online suchen</mat-label>
<input class="w-full" type="text" matInput [formControl]="formControl">
<input class="w-full" type="text" matInput [formControl]="formControl"/>
@if (formControl.hasError('noResults')) {
<mat-error>Keine Suchergebnisse gefunden</mat-error>
}

View File

@@ -1,9 +1,10 @@
import { LoadingService } from './../loading.service';
import { Component, inject } from '@angular/core';
import { Component, DestroyRef, inject } from '@angular/core';
import { FormControl } from '@angular/forms';
import { catchError, finalize, of, tap } from 'rxjs';
import { HttpService } from '../http.service';
import { OmdbMovie } from '@infinimotion/model-frontend';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-movie-importer',
@@ -21,13 +22,15 @@ export class MovieImporterComponent {
private httpService = inject(HttpService)
public loadingService = inject(LoadingService)
private destroyRef = inject(DestroyRef);
DoSubmit() {
this.showAll = false;
this.searchForMovies();
}
searchForMovies() {
private searchForMovies() {
this.search_query = this.formControl.value?.trim() || '';
if (this.search_query?.length == 0) return;
@@ -48,7 +51,8 @@ export class MovieImporterComponent {
finalize(() => {
this.isSearching = false;
this.formControl.enable();
})
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}

View File

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

View File

@@ -11,6 +11,7 @@ export class NavbarComponent {
navItems: { label:string, path:string }[] = [
{label: 'Programm', path: '/schedule'},
{label: 'Preise', path: '/prices'},
{label: 'Bezahlen', path: '/checkout/order'},
{label: 'Film importieren', path: '/admin/movie-importer'},
{label: 'Statistiken', path: '/admin/statistics'},
]

View File

@@ -1,3 +1,7 @@
:host {
min-width: 500px;
}
mat-stepper {
background: transparent !important;
}

View File

@@ -27,43 +27,47 @@
</div>
}
<mat-stepper orientation="horizontal" linear="true" [disableRipple]="true" (selectionChange)="onStepChange($event)" #stepper>
<mat-step>
<ng-template matStepLabel>Warenkorb</ng-template>
<mat-stepper orientation="horizontal" linear="true" [selectedIndex]="isResuming()? 2 : 0" [disableRipple]="true" (selectionChange)="onStepChange($event)" #stepper>
<mat-step [editable]="!isResuming() && !(isReservationSuccess() && !isSubmitting()) && !(isPurchaseSuccess() && !isSubmitting())">
<ng-template matStepLabel>Warenkorb</ng-template>
<div class="performance-info-space"></div>
<div class="performance-info-space"></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>
@if (selectedSeatsService.hadConflict()) {
<app-selection-conflict-info></app-selection-conflict-info>
}
<mat-divider></mat-divider>
<!-- 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>
<!-- 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>
<mat-divider></mat-divider>
<!-- 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>
<!-- 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">
<mat-step [editable]="!isResuming() && !(isPurchaseSuccess() && !isSubmitting())" [completed]="isResuming() || dataForm.valid || isPurchaseSuccess() || isSubmitting()" [stepControl]="dataForm">
<form [formGroup]="dataForm">
<ng-template matStepLabel>Anschrift</ng-template>
@@ -71,11 +75,11 @@
@if (isReservationSuccess() && !isSubmitting()) {
<div class="h-4"></div>
<app-reservation-success [order]="createdOrder()!"></app-reservation-success>
<app-reservation-success [order]="createdOrder()!" [moreThanOne]="totalSeats() > 1"></app-reservation-success>
}
@else if (isReservationError() && !isSubmitting()) {
@else if (isReservationError() && !isSubmitting() && performance()) {
<div class="h-4"></div>
<app-reservation-failed></app-reservation-failed>
<app-reservation-failed (retry)="retryReservation()" [performanceId]="performance()!.id" [moreThanOne]="totalSeats() > 1"></app-reservation-failed>
}
@else {
@@ -119,11 +123,23 @@
@if (isPurchaseSuccess() && !isSubmitting()) {
<div class="h-4"></div>
<app-purchase-success [tickets]="createdTickets()"></app-purchase-success>
<app-purchase-success [tickets]="createdTickets()" [moreThanOne]="totalSeats() > 1"></app-purchase-success>
}
@else if (isPurchaseError() && !isSubmitting()) {
@else if (isPurchaseError() && !isSubmitting() && performance()) {
<div class="h-4"></div>
<app-purchase-failed></app-purchase-failed>
<app-purchase-failed (retry)="retryPurchase()" [performanceId]="performance()!.id" [moreThanOne]="totalSeats() > 1"></app-purchase-failed>
}
@else if (isConversionError() && !isSubmitting()) {
<div class="h-4"></div>
<app-conversion-failed (retry)="retryConversion()" [moreThanOne]="totalSeats() > 1"></app-conversion-failed >
}
@else if (isCancellationSuccess() && !isSubmitting() && performance()) {
<div class="h-4"></div>
<app-cancellation-success [performanceId]="performance()!.id" [moreThanOne]="totalSeats() > 1"></app-cancellation-success>
}
@else if (isCancellationError() && !isSubmitting()) {
<div class="h-4"></div>
<app-cancellation-failed (retry)="retryCancellation()" [moreThanOne]="totalSeats() > 1"></app-cancellation-failed>
}
@else {
@@ -179,9 +195,17 @@
<!-- 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>
@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">
Zurück
</button>
}
<button mat-flat-button color="accent" matStepperNext type="submit" [disabled]="isSubmitting()" class="w-2/3">
{{ getPriceDisplay(totalPrice()) }} jetzt bezahlen
</button>

View File

@@ -1,13 +1,15 @@
import { SelectedSeatsService } from './../selected-seats.service';
import { LoadingService } from './../loading.service';
import { Bestellung, Eintrittskarte, Sitzkategorie, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend';
import { Component, computed, DestroyRef, inject, input, signal } from '@angular/core';
import { Component, computed, DestroyRef, inject, input, output, 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, switchMap, map, EMPTY, forkJoin } from 'rxjs';
import { catchError, tap, finalize, EMPTY } from 'rxjs';
import { MatStepper } from '@angular/material/stepper';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { CancelOrderDialog } from '../cancel-order/cancel-order.dialog';
type OrderState =
| { status: 'idle' }
@@ -15,7 +17,10 @@ type OrderState =
| { status: 'reservation-success'; order: Bestellung }
| { status: 'reservation-error'; error: any }
| { 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';
@@ -29,12 +34,20 @@ export class OrderComponent {
private fb = inject(FormBuilder);
private httpService = inject(HttpService);
private destroyRef = inject(DestroyRef);
private dialog = inject(MatDialog);
readonly loadingService = inject(LoadingService);
readonly selectedSeatsService = inject(SelectedSeatsService);
performance = input<Vorstellung>();
seatCategories = input.required<Sitzkategorie[]>();
existingOrder = input<Bestellung>();
existingTickets = input<Eintrittskarte[]>();
resumeWithCancel = input<boolean>(true);
stepChanged = output<number>();
paymentForm!: FormGroup;
dataForm!: FormGroup;
@@ -49,6 +62,16 @@ export class OrderComponent {
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(() => {
const mode = this.submissionMode();
if (!mode) return 'Loading...';
@@ -67,6 +90,10 @@ export class OrderComponent {
this.orderState().status === 'purchase-success'
);
isCancellationSuccess = computed(() =>
this.orderState().status === 'cancellation-success'
);
isReservationError = computed(() =>
this.orderState().status === 'reservation-error'
);
@@ -75,6 +102,14 @@ export class OrderComponent {
this.orderState().status === 'purchase-error'
);
isConversionError = computed(() =>
this.orderState().status === 'conversion-error'
);
isCancellationError = computed(() =>
this.orderState().status === 'cancellation-error'
);
createdOrder = computed(() => {
const state = this.orderState();
return state.status === 'reservation-success' ? state.order : null;
@@ -99,6 +134,10 @@ export class OrderComponent {
cvv: ['', [Validators.required, Validators.pattern(/^\d{3,4}$/)]],
});
this.confetti = (await import('canvas-confetti')).default;
if (this.resumeWithCancel()) {
this.cancelReservation();
}
}
get fData() { return this.dataForm.controls; }
@@ -107,6 +146,8 @@ export class OrderComponent {
onStepChange(event: StepperSelectionEvent) {
this.submitted.set(false);
this.selectedSeatsService.setSeatSelectable(event.selectedIndex === 0);
this.stepChanged.emit(event.selectedIndex);
}
reservationClicked() {
@@ -128,12 +169,12 @@ export class OrderComponent {
this.makeReservation();
} else if (this.submissionMode() === 'purchase') {
stepper.next();
}
}
makeReservation() {
this.orderState.set({ status: 'submitting' });
this.loadingService.show();
this.disableForms();
const order = this.generateNewOrderObject(this.dataForm.value.email, false);
@@ -152,15 +193,51 @@ export class OrderComponent {
this.loadingService.show();
this.disableForms();
const order = this.generateNewOrderObject(this.dataForm.value.email, true);
const seats = this.selectedSeatsService.selectedSeats();
const performance = this.performance()!;
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 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.show();
// Tickets anlegen
const tickets = seats.map(seat => {
@@ -189,8 +266,9 @@ export class OrderComponent {
}),
catchError(err => {
// Error handling
console.error('Fehler beim Anlegen der Bestellung/Tickets:', err);
this.selectedSeatsService.error();
this.loadingService.showError(err);
console.error('Fehler beim Anlegen der Bestellung/Tickets:', err);
if (mode === 'reservation') {
this.orderState.set({ status: 'reservation-error', error: err });
@@ -255,6 +333,50 @@ export class OrderComponent {
};
}
cancelReservation() {
const dialogRef = this.dialog.open(CancelOrderDialog, {
width: '500px',
disableClose: false,
enterAnimationDuration: '200ms',
exitAnimationDuration: '100ms'
});
dialogRef.afterClosed().subscribe(confirmed => {
if (confirmed) {
this.performCancellation();
}
});
}
private performCancellation() {
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 {
this.dataForm.disable();
this.paymentForm.disable();
@@ -268,4 +390,26 @@ export class OrderComponent {
getPriceDisplay(price: number): string {
return `${(price / 100).toFixed(2)}`;
}
retryPurchase() {
this.orderState.set({ status: 'idle' });
this.makePurchase();
}
retryReservation() {
this.orderState.set({ status: 'idle' });
this.makeReservation();
}
retryConversion() {
this.orderState.set({ status: 'idle' });
const order = this.existingOrder()!;
order.booked = new Date();
this.convertOrder(order, this.existingTickets()!);
}
retryCancellation() {
this.orderState.set({ status: 'idle' });
this.cancelReservation();
}
}

View File

@@ -0,0 +1,7 @@
.middle {
position: relative;
top: 40%;
-webkit-transform: translateY(-50%);
-ms-transform: translateY(-50%);
transform: translateY(-50%);
}

View File

@@ -0,0 +1,40 @@
<app-menu-header label="Tickets bezahlen" icon="payment_card"></app-menu-header>
<div class="w-100 m-auto middle">
<form class="order-search-form w-full" (ngSubmit)="DoSubmit()">
<div class="flex items-center space-x-4">
<mat-form-field class="w-full" subscriptSizing="dynamic">
<mat-label>Reservierungsnummer eingeben</mat-label>
<input class="w-full" type="text" matInput [formControl]="formControl" (input)="onInput($event)" placeholder="XXXXXX" maxlength="6" autocomplete="off">
<mat-error>
@if (formControl.hasError('invalid')) {
Ungültiger Reservierungscode
}
@else if (formControl.hasError('completed')) {
Diese Bestellung wurde bereits abgeschlossen
}
@else if (formControl.hasError('required')) {
Bitte geben Sie Ihren Code ein
}
@else if (formControl.hasError('severalOrders')) {
Mehrere Bestellungen gefunden - bitte kontaktieren Sie den Support
}
@else if (formControl.hasError('alreadyBooked')) {
Diese Bestellung wurde bereits bezahlt
}
@else if (formControl.hasError('cancelled')) {
Diese Reservierung wurde storniert
}
@else if (formControl.hasError('serverError')) {
Fehler beim Laden der Reservierung
}
</mat-error>
</mat-form-field>
</div>
@if (formControl.valid || !formControl.touched) {
<div class="h-6"></div>
}
<button mat-button class="w-100 mt-2" matButton="filled" color="accent" [disabled]="(loadingService.loading$ | async) || formControl.invalid" type="submit">Tickets jetzt online bezahlen</button>
</form>
</div>

View File

@@ -0,0 +1,112 @@
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { LoadingService } from '../loading.service';
import { HttpService } from '../http.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { catchError, finalize, map, of, take } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'app-pay-for-order',
standalone: false,
templateUrl: './pay-for-order.component.html',
styleUrl: './pay-for-order.component.css',
})
export class PayForOrderComponent implements OnInit {
private httpService = inject(HttpService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private destroyRef = inject(DestroyRef);
public loadingService = inject(LoadingService);
queryError?: string;
formControl = new FormControl('', {
validators: [
Validators.required,
Validators.minLength(6),
Validators.maxLength(6)
]
});
ngOnInit() {
const error = this.route.snapshot.queryParamMap.get('error');
const code = this.route.snapshot.queryParamMap.get('code');
if (code) {
this.formControl.setValue(code);
}
if (error) {
// Warte einen Tick, damit Angular das FormControl initialisiert hat
setTimeout(() => {
this.formControl.clearValidators();
this.formControl.setErrors({ [error]: true });
this.formControl.markAsTouched();
});
// Bei erster Änderung: Validatoren wieder aktivieren
this.formControl.valueChanges.pipe(
take(1),
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.formControl.setValidators([
Validators.required,
Validators.minLength(6),
Validators.maxLength(6)
]);
this.formControl.updateValueAndValidity();
});
}
}
onInput(event: Event) {
this.queryError = undefined;
const input = event.target as HTMLInputElement;
const filtered = input.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
this.formControl.setValue(filtered, { emitEvent: false });
}
DoSubmit() {
this.formControl.markAsTouched();
if (this.formControl.invalid) return;
const code = this.formControl.value?.trim();
if (!code || code.length !== 6) return;
this.loadingService.show();
const orderFilter = [`eq;code;string;${code}`];
this.httpService.getOrdersByFilter(orderFilter).pipe(
map(orders => {
this.loadingService.hide();
if (orders.length === 0) {
this.formControl.setErrors({ invalid: true });
return
}
if (orders.length > 1) {
this.formControl.setErrors({ severalOrders: true });
return;
}
const order = orders[0];
if (order.booked) {
this.formControl.setErrors({ alreadyBooked: true });
return;
}
if (order.cancelled) {
this.formControl.setErrors({ cancelled: true });
return;
}
this.router.navigate(['/checkout/order', order.code]);
}),
catchError(err => {
this.loadingService.hide();
this.loadingService.showError(err);
this.formControl.setErrors({ serverError: true });
return of(null);
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
}

View File

@@ -3,9 +3,9 @@
warning
</mat-icon>
<h1 class="text-xl font-bold">Kauf fehlgeschlagen!</h1>
<p class="text-center">Leider konnten Ihre Sitzplätze nicht gekauft werden. Dies kann passieren, wenn andere Nutzer zeitgleich versucht haben, dieselben Sitzplätze zu kaufen.</p>
<p class="text-center">{{ infoText }}</p>
<button mat-button matButton="filled" class="error-button mt-4 w-80">Erneut versuchen</button>
<button routerLink="/schedule" mat-button matButton="outlined" color="accent" class="error-button w-80 mt-1">Zurück zur Programmauswahl</button>
<button mat-button type="button" matButton="filled" class="error-button mt-4 w-80" (click)="retry.emit()">Erneut versuchen</button>
<button mat-button type="button" matButton="outlined" color="accent" class="error-button w-80 mt-1" (click)="navigate()">Zurück zur Sitzplatzauswahl</button>
</div>

View File

@@ -1,4 +1,5 @@
import { Component } from '@angular/core';
import { Component, inject, input, output } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-purchase-failed',
@@ -7,5 +8,22 @@ import { Component } from '@angular/core';
styleUrl: './purchase-failed.component.css',
})
export class PurchaseFailedComponent {
performanceId = input.required<number>();
moreThanOne = input<boolean>(false);
retry = output<void>();
private router = inject(Router);
infoText!: string;
ngOnInit(): void {
this.infoText = this.moreThanOne()?
'Leider konnten Ihre Sitzplätze nicht gekauft werden. Dies kann passieren, wenn andere Nutzer zeitgleich versucht haben, dieselben Sitzplätze zu kaufen.' :
'Leider konnte Ihr Sitzplatz nicht gekauft werden. Dies kann passieren, wenn andere Nutzer zeitgleich versucht haben, denselben Sitzplatz zu kaufen.';
}
navigate() {
window.location.href = `/checkout/performance/${this.performanceId()}`;
}
}

View File

@@ -1,11 +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">
<h1 class="text-xl font-bold">Vielen Dank für Ihren Einkauf!</h1>
<p class="text-center">Ihre Sitzplätze wurden erfolgreich gebucht.</p>
<p class="text-center">{{ infoText }}</p>
<app-ticket-list [tickets]="tickets()" class="w-8/10 my-4"></app-ticket-list>
<button mat-button disabled="true" matButton="filled" class="success-button w-80 mt-4">Tickets herunterladen</button>
<button routerLink="/schedule" mat-button matButton="outlined" color="accent" class="success-button w-80 mt-1">Zurück zur Programmauswahl</button>
<button mat-button disabled="true" matButton="filled" class="success-button w-80 mt-4">{{ buttonText }}</button>
<button routerLink="/schedule" type="button" mat-button matButton="outlined" color="accent" class="success-button w-80 mt-1">Zur Programmauswahl</button>
</div>

View File

@@ -9,4 +9,18 @@ import { Component, input } from '@angular/core';
})
export class PurchaseSuccessComponent {
tickets = input.required<Eintrittskarte[]>();
moreThanOne = input<boolean>(false);
infoText!: string;
buttonText!: string;
ngOnInit(): void {
this.infoText = this.moreThanOne()?
'Ihre Sitzplätze wurden erfolgreich gebucht.' :
'Ihr Sitzplatz wurden erfolgreich gebucht.';
this.buttonText = this.moreThanOne()?
'Tickets herunterladen' :
'Ticket herunterladen';
}
}

View File

@@ -3,9 +3,9 @@
warning
</mat-icon>
<h1 class="text-xl font-bold">Reservierung fehlgeschlagen!</h1>
<p class="text-center">Leider konnten Ihre Sitzplätze nicht reserviert werden. Dies kann passieren, wenn andere Nutzer gleichzeitig versucht haben, dieselben Sitzplätze zu reservieren.</p>
<p class="text-center">{{ infoText }}</p>
<button mat-button matButton="filled" class="error-button mt-4 w-80">Erneut versuchen</button>
<button routerLink="/schedule" mat-button matButton="outlined" color="accent" class="error-button w-80 mt-1">Zurück zur Programmauswahl</button>
<button mat-button type="button" matButton="filled" class="error-button mt-4 w-80" (click)="retry.emit()">Erneut versuchen</button>
<button mat-button type="button" matButton="outlined" color="accent" class="error-button w-80 mt-1" (click)="navigate()">Zurück zur Sitzplatzauswahl</button>
</div>

View File

@@ -1,4 +1,5 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { Component, inject, input, output } from '@angular/core';
@Component({
selector: 'app-reservation-failed',
@@ -7,5 +8,22 @@ import { Component } from '@angular/core';
styleUrl: './reservation-failed.component.css',
})
export class ReservationFailedComponent {
performanceId = input.required<number>();
moreThanOne = input<boolean>(false);
retry = output<void>();
router = inject(Router)
infoText!: string;
ngOnInit(): void {
this.infoText = this.moreThanOne()?
'Leider konnten Ihre Sitzplätze nicht reserviert werden. Dies kann passieren, wenn andere Nutzer gleichzeitig versucht haben, dieselben Sitzplätze zu reservieren.' :
'Leider konnte Ihr Sitzplatz nicht reserviert werden. Dies kann passieren, wenn andere Nutzer gleichzeitig versucht haben, denselben Sitzplatz zu reservieren.';
}
navigate() {
window.location.href = `/checkout/performance/${this.performanceId()}`;
}
}

View File

@@ -1,14 +1,14 @@
<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">
<h1 class="text-xl font-bold">Reservierung erfolgreich!</h1>
<p class="text-center">Ihre Sitzplätze wurden erfolgreich reserviert. Bitte nennen sie den folgenden Code an der Kasse, um Ihre Reservierung in eine Buchung umzuwandeln.</p>
<p class="text-center" style="white-space: pre-line;">{{ infoText }}</p>
<div class="bg-white text-5xl font-mono rounded-md shadow-sm w-fit h-fit p-4 py-2 my-4">
<strong>{{ order().code }}</strong>
</div>
<button [disabled]="true" 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>
<div class="text-green-500 cursor-pointer w-fit mt-2" (click)="cancelReservation()">
<button routerLink="/checkout/order/{{ order().code }}" type="button" mat-button matButton="filled" color="accent" class="success-button mt-2 w-80">{{ buttonText }}</button>
<button routerLink="/schedule" type="button" mat-button matButton="outlined" class="success-button mb-4 w-80 mt-1">Zurück zur Programmauswahl</button>
<div [routerLink]="['/checkout/order', order().code]" [queryParams]="{ action: 'cancel' }" class="text-green-500 cursor-pointer w-fit mt-2">
Reservierung stornieren
</div>

View File

@@ -1,5 +1,5 @@
import { Bestellung } from '@infinimotion/model-frontend';
import { Component, input } from '@angular/core';
import { Component, input, OnInit, output } from '@angular/core';
@Component({
selector: 'app-reservation-success',
@@ -7,10 +7,20 @@ import { Component, input } from '@angular/core';
templateUrl: './reservation-success.component.html',
styleUrl: './reservation-success.component.css',
})
export class ReservationSuccessComponent {
export class ReservationSuccessComponent implements OnInit {
order = input.required<Bestellung>();
moreThanOne = input<boolean>(false);
cancelReservation() {
// Logic to cancel the reservation
infoText!: string;
buttonText!: string;
ngOnInit(): void {
this.infoText = this.moreThanOne()?
'Ihre Sitzplätze wurden erfolgreich reserviert.\nBitte nennen sie den folgenden Code an der Kasse, um Ihre Reservierung in eine Buchung umzuwandeln' :
'Ihr Sitzplatz wurde erfolgreich reserviert.\nBitte nennen sie den folgenden Code an der Kasse, um Ihre Reservierung in eine Buchung umzuwandeln';
this.buttonText = this.moreThanOne()?
'Tickets jetzt online bezahlen' :
'Ticket jetzt online bezahlen';
}
}

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
[class]="isHoverable()? 'hover:opacity-50' : ''"
[ngStyle]="{color : getSeatStateColor() }"

View File

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

View File

@@ -1,5 +1,5 @@
import { computed, Injectable, signal } from '@angular/core';
import {Sitzplatz} from '@infinimotion/model-frontend';
import { Sitzplatz } from '@infinimotion/model-frontend';
@Injectable({
providedIn: 'root',
@@ -9,22 +9,30 @@ export class SelectedSeatsService {
private selectedSeatsSignal = signal<Sitzplatz[]>([]);
private seatIsSelectableSignal = signal(true);
private committedSignal = signal(false);
private erroredSignal = signal(false);
private cancelledSignal = signal(false);
private debugSignal = signal(false);
private hadConflictSignal = signal(false);
readonly selectedSeats = this.selectedSeatsSignal.asReadonly();
readonly seatIsSelectable = this.seatIsSelectableSignal.asReadonly();
readonly committed = this.committedSignal.asReadonly();
readonly errored = this.erroredSignal.asReadonly();
readonly cancelled = this.cancelledSignal.asReadonly();
readonly debug = this.debugSignal.asReadonly();
readonly hadConflict = this.hadConflictSignal.asReadonly();
readonly totalSeats = computed(() => this.selectedSeats().length);
readonly totalPrice = computed(() => this.selectedSeats().reduce((sum, seat) => sum + seat.row.category.price, 0));
pushSelectedSeat(selectedSeat: Sitzplatz): void {
this.selectedSeatsSignal.update(seats => [...seats, selectedSeat]);
this.hadConflictSignal.set(false);
}
removeSelectedSeat(selectedSeat: Sitzplatz): void {
this.selectedSeatsSignal.update(seats => seats.filter(seat => seat.id !== selectedSeat.id));
this.hadConflictSignal.set(false);
}
getSeatsByCategory(categoryId: number): Sitzplatz[] {
@@ -34,6 +42,9 @@ export class SelectedSeatsService {
clearSelection(): void {
this.selectedSeatsSignal.set([]);
this.committedSignal.set(false);
this.cancelledSignal.set(false);
this.erroredSignal.set(false);
this.hadConflictSignal.set(false);
}
getSeatIsSelectable(): boolean{
@@ -44,13 +55,25 @@ export class SelectedSeatsService {
this.seatIsSelectableSignal.set(selectable);
if (selectable) {
this.committedSignal.set(false);
this.cancelledSignal.set(false);
this.erroredSignal.set(false);
}
}
commit(): void {
this.erroredSignal.set(false);
this.committedSignal.set(true);
}
error(): void {
this.erroredSignal.set(true);
}
cancel(): void {
this.erroredSignal.set(false);
this.cancelledSignal.set(true);
}
toggleDebug(): void {
this.debugSignal.update(debug => !debug);
}
@@ -58,4 +81,8 @@ export class SelectedSeatsService {
isSeatSelected(seatId: number): boolean {
return this.selectedSeats().some(seat => seat.id === seatId);
}
setConflict(value: boolean): void {
this.hadConflictSignal.set(value);
}
}

View File

@@ -0,0 +1,16 @@
<div class="info-box bg-orange-100 h-22 w-full flex items-center space-x-4 rounded-md p-1 px-3 shadow-sm">
<div class="w-fit">
<mat-icon class="material-symbols-outlined mt-1" style="font-size: 40px; width: 40px; height: 40px;">
warning
</mat-icon>
</div>
<div class="text-md">
<div class="flex space-x-1.5">
<h3 class="font-semibold">Sitzplatz aus dem Warenkorb entfernt!</h3>
</div>
<p>Leider ist der von Ihnen gewählte Sitzplazt nicht mehr verfügbar. Bitte wählen Sie einen anderen.</p>
</div>
</div>

View File

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

View File

@@ -1 +0,0 @@
<p>shopping-cart works!</p>

View File

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

View File

@@ -1,4 +1,4 @@
<div class="m-auto w-200 h-10 bg-gray-200 mb-22" style="clip-path: polygon(0% 0%,100% 0%,90% 100%,10% 100%);" (click)="selectedSeatsService.toggleDebug()">
<div class="m-auto w-200 h-10 bg-gray-200 mb-22 cursor-pointer" style="clip-path: polygon(0% 0%,100% 0%,90% 100%,10% 100%);" (click)="selectedSeatsService.toggleDebug()">
<p class="flex justify-center text-lg font-bold p-1.5">
Leinwand
</p>

View File

@@ -4,7 +4,7 @@
<div class="w-7/10 p-10 h-fit">
<div>
@if (!performance && (loading.loading$ | async)){
@if (!performance() && (loading.loading$ | async)){
<div class="w-full h-full flex items-center justify-center mt-70">
<mat-progress-spinner
mode="indeterminate"
@@ -18,6 +18,6 @@
</div>
</div>
<app-order 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" [resumeWithCancel] = "resumeWithCancel"></app-order>
</div>

View File

@@ -1,14 +1,16 @@
import {Component, DestroyRef, inject, OnDestroy, OnInit, signal} from '@angular/core';
import {HttpService} from '../http.service';
import {LoadingService} from '../loading.service';
import {catchError, filter, forkJoin, interval, of, startWith, switchMap, tap} from 'rxjs';
import {Sitzkategorie, Sitzplatz, Vorstellung} from '@infinimotion/model-frontend';
import {TheaterSeatState} from '../model/theater-seat-state.model';
import {ActivatedRoute} from '@angular/router';
import {SelectedSeatsService} from '../selected-seats.service';
import { Component, DestroyRef, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { HttpService } from '../http.service';
import { LoadingService } from '../loading.service';
import { catchError, filter, finalize, forkJoin, from, fromEvent, interval, merge, of, startWith, switchMap, tap } from 'rxjs';
import { Bestellung, Eintrittskarte, Sitzkategorie, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend';
import { TheaterSeatState } from '../model/theater-seat-state.model';
import { ActivatedRoute, Router } from '@angular/router';
import { SelectedSeatsService } from '../selected-seats.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatSnackBar, MatSnackBarRef, TextOnlySnackBar } from '@angular/material/snack-bar';
const POLLING_INTERVAL_MS = 5000;
const POLLING_INTERVAL_MS = 5 * 1000;
const INACTIVITY_TIMEOUT_MS = 2 * 60 * 1000;
@Component({
selector: 'app-theater-overlay',
@@ -19,79 +21,294 @@ const POLLING_INTERVAL_MS = 5000;
export class TheaterOverlayComponent implements OnInit, OnDestroy {
private http = inject(HttpService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
private selectedSeatService = inject(SelectedSeatsService);
readonly loading = inject(LoadingService);
showId!: number;
orderId?: string;
seatsPerRow = signal<{ seat: Sitzplatz | null, state: TheaterSeatState | null }[][]>([]);
performance: Vorstellung | undefined;
showId?: number | null;
orderId?: number;
orderCode?: string | null;
isResuming = false;
resumeWithCancel = false;
tickets: Eintrittskarte[] | undefined;
order: Bestellung | undefined;
blockedSeats: Sitzplatz[] | undefined;
seatCategories: Sitzkategorie[] = [];
snackBarRef: MatSnackBarRef<TextOnlySnackBar> | undefined
performance = signal<Vorstellung | undefined>(undefined);
seatsPerRow = signal<{ seat: Sitzplatz | null, state: TheaterSeatState | null }[][]>([]);
private isPollingEnabled = signal(true);
private isInitialLoad = signal(true);
private lastActivityTimestamp = signal(Date.now());
private inactivityTimeoutReached = signal(false);
private isRequestInProgress = signal(false);
private isStepTwoOrHigher = signal(false);
constructor(private snackBar: MatSnackBar) {}
ngOnInit() {
this.showId = Number(this.route.snapshot.paramMap.get('performanceId')!);
this.orderId = this.route.snapshot.queryParams['paramName'];
this.selectedSeatService.clearSelection();
this.selectedSeatService.setSeatSelectable(true);
this.orderCode = this.route.snapshot.paramMap.get('orderId');
this.startAutoRefresh();
if (this.orderCode) {
// Checkout fortsetzen
this.isResuming = true;
this.loadExistingOrder(this.orderCode);
this.route.queryParams.subscribe(params => {
if (params['action'] === 'cancel') {
this.resumeWithCancel = true;
}
});
} 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() {
this.isPollingEnabled.set(false);
console.info('[TheaterOverlay] Stopped auto-refresh polling');
if(this.snackBarRef) {
this.snackBar.dismiss();
}
}
private setupActivityTracking() {
console.info('[TheaterOverlay] Setting up activity tracking');
const events$ = merge(
fromEvent(document, 'mousemove'),
fromEvent(document, 'mousedown'),
fromEvent(document, 'keypress'),
fromEvent(document, 'scroll'),
fromEvent(document, 'touchstart')
);
events$.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.lastActivityTimestamp.set(Date.now());
});
fromEvent(document, 'visibilitychange').pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
if (!this.isStepTwoOrHigher()) {
if (document.hidden) {
console.info('[TheaterOverlay] Tab hidden - pausing polling');
this.pausePolling();
} else {
// Nur in Schritt 1 reaktivieren
if (!this.inactivityTimeoutReached()) {
console.info('[TheaterOverlay] Tab visible - resumed polling');
this.isPollingEnabled.set(true);
this.refreshSeats();
}
}
}
});
}
private startInactivityCheck() {
interval(10000).pipe(
filter(() => this.isPollingEnabled()),
filter(() => !this.isStepTwoOrHigher()), // Kein Timeout ab Schritt 2
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
const inactiveDuration = Date.now() - this.lastActivityTimestamp();
if (inactiveDuration >= INACTIVITY_TIMEOUT_MS && !this.inactivityTimeoutReached()) {
console.info('[TheaterOverlay] Inactivity timeout reached');
this.handleInactivityTimeout();
}
});
}
private handleInactivityTimeout() {
this.inactivityTimeoutReached.set(true);
this.pausePolling();
this.showInactivitySnackBar();
}
private showInactivitySnackBar() {
this.snackBarRef = this.snackBar.open(
'Sitzplatzaktuallisierung wegen Inaktivität gestoppt.',
'Fortsetzen',
{
duration: 0,
panelClass: ['timeout-snackbar'],
horizontalPosition: 'center',
verticalPosition: 'bottom'
}
);
this.snackBarRef.afterDismissed().subscribe(() => {
this.lastActivityTimestamp.set(Date.now());
this.inactivityTimeoutReached.set(false);
this.resumePolling();
this.refreshSeats();
});
}
private startAutoRefresh() {
console.info('[TheaterOverlay] Starting auto-refresh polling');
interval(POLLING_INTERVAL_MS).pipe(
startWith(0),
startWith(POLLING_INTERVAL_MS),
filter(() => this.isPollingEnabled()),
filter(() => !this.selectedSeatService.committed()),
filter(() => !document.hidden), // Nicht pollen, wenn Tab nicht sichtbar
filter(() => !this.inactivityTimeoutReached()), // Nicht pollen nach Timeout
filter(() => !this.isRequestInProgress()), // Nicht pollen, wenn Request läuft
filter(() => !this.isStepTwoOrHigher()), // Nicht pollen ab Schritt 2
switchMap(() => this.loadPerformanceAndSeats()),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
private loadPerformanceAndSeats() {
if (this.isInitialLoad()) {
const isInitial = this.isInitialLoad();
if (isInitial) {
this.loading.show();
}
this.isRequestInProgress.set(true);
return forkJoin({
performance: this.http.getPerformaceById(this.showId),
seats: this.http.getSeatsByShowId(this.showId)
performance: this.http.getPerformaceById(this.showId!),
seats: this.http.getSeatsByShowId(this.showId!)
}).pipe(
tap(({ performance, seats }) => {
this.performance = performance;
this.performance.set(performance);
if (this.blockedSeats && !this.equalSeats(this.blockedSeats, seats.reserved)) {
console.info('[TheaterOverlay] External booking detected. Checking for conflicts.');
const conflicts = this.getConflictingSeats(seats.reserved);
if (conflicts.length > 0) {
console.info('[TheaterOverlay] Conflicts! Updating shopping cart.');
conflicts.forEach(seat => this.selectedSeatService.removeSelectedSeat(seat));
this.selectedSeatService.setConflict(true);
}
this.selectedSeatService.selectedSeats
}
this.blockedSeats = seats.reserved;
this.seatsPerRow.set(this.converter(seats));
if (this.isInitialLoad()) {
if (isInitial) {
this.loading.hide();
this.isInitialLoad.set(false);
}
}),
catchError(err => {
if (this.isInitialLoad()) {
if (isInitial) {
this.loading.showError(err);
} else {
console.warn('Fehler beim Aktualisieren der Sitze:', err);
}
if (this.isInitialLoad()) {
this.loading.hide();
return of({ performance: null, seats: { seats: [], reserved: [], booked: [] } });
}),
finalize(() => {
if (isInitial) {
this.isInitialLoad.set(false);
}
return of({ performance: null, seats: { seats: [], reserved: [], booked: [] } });
this.isRequestInProgress.set(false);
})
);
}
converter(resp: { seats: Sitzplatz[], reserved: Sitzplatz[], booked: Sitzplatz[] }): {
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', code: orderCode } }
));
}
this.order = tickets[0].order;
if (this.order.booked || this.order.cancelled) {
return from(this.router.navigate(
['/checkout/order'],
{ queryParams: { error: 'completed', code: orderCode } }
));
}
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 => {
console.error('Fehler beim Laden der Bestellung', err);
return from(this.router.navigate(
['/checkout/order'],
{ queryParams: { error: 'invalid', code: orderCode } }
));
}),
finalize(() => {
this.loading.hide();
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
private equalSeats(a: Sitzplatz[], b: Sitzplatz[]): boolean {
if (a === b) return true;
if (a == null || b == null) return false;
if (a.length !== b.length) return false;
// Arrays kopieren und sortieren
const sortedA = [...a].sort((a, b) => a.id - b.id);
const sortedB = [...b].sort((a, b) => a.id - b.id);
for (let i = 0; i < sortedA.length; ++i) {
if (sortedA[i].id !== sortedB[i].id) return false;
}
return true;
}
private getConflictingSeats(blockedSeats: Sitzplatz[]): Sitzplatz[] {
const blockedIds = new Set(blockedSeats.map(bs => bs.id));
return this.selectedSeatService.selectedSeats().filter(
selectedSeat => blockedIds.has(selectedSeat.id)
);
}
private converter(resp: { seats: Sitzplatz[], reserved: Sitzplatz[], booked: Sitzplatz[] }): {
seat: Sitzplatz | null,
state: TheaterSeatState | null
}[][] {
@@ -163,16 +380,36 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
return filledRows;
}
refreshSeats(): void {
private refreshSeats(): void {
console.info('[TheaterOverlay] Manual refresh triggered');
this.loadPerformanceAndSeats().subscribe();
}
pausePolling(): void { //TODO: Ab Stepper Schritt 2 Polling pausieren
private pausePolling(): void {
console.info('[TheaterOverlay] Polling paused');
this.isPollingEnabled.set(false);
}
resumePolling(): void {
this.isPollingEnabled.set(true);
private resumePolling(): void {
console.info('[TheaterOverlay] Resume polling attempted');
if (!this.inactivityTimeoutReached() && !this.isStepTwoOrHigher()) {
this.isPollingEnabled.set(true);
}
}
setStepTwoOrHigher(isStep2OrHigher: boolean): void {
this.isStepTwoOrHigher.set(isStep2OrHigher);
if (isStep2OrHigher) {
console.info('[TheaterOverlay] Moving to step 2+ - disabling polling');
this.pausePolling();
} else {
console.info('[TheaterOverlay] Back to step 1 - reactivating polling');
this.lastActivityTimestamp.set(Date.now());
this.inactivityTimeoutReached.set(false);
this.isPollingEnabled.set(true);
this.refreshSeats();
}
}
}