diff --git a/src/app/app-module.ts b/src/app/app-module.ts index 7343d2a..4907717 100644 --- a/src/app/app-module.ts +++ b/src/app/app-module.ts @@ -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: [ diff --git a/src/app/app-routing-module.ts b/src/app/app-routing-module.ts index 72de746..9b8c10d 100644 --- a/src/app/app-routing-module.ts +++ b/src/app/app-routing-module.ts @@ -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, diff --git a/src/app/cancel-order/cancel-order.dialog.css b/src/app/cancel-order/cancel-order.dialog.css new file mode 100644 index 0000000..f785beb --- /dev/null +++ b/src/app/cancel-order/cancel-order.dialog.css @@ -0,0 +1,3 @@ +button { + min-width: 100px; +} diff --git a/src/app/cancel-order/cancel-order.dialog.html b/src/app/cancel-order/cancel-order.dialog.html new file mode 100644 index 0000000..0c953f3 --- /dev/null +++ b/src/app/cancel-order/cancel-order.dialog.html @@ -0,0 +1,10 @@ +

Möchten Sie Ihre Bestellung wirklich stornieren?

+ + +

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.

+
+ + + + + diff --git a/src/app/cancel-order/cancel-order.dialog.ts b/src/app/cancel-order/cancel-order.dialog.ts new file mode 100644 index 0000000..44dc764 --- /dev/null +++ b/src/app/cancel-order/cancel-order.dialog.ts @@ -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, + ) {} + + submit(): void { + this.dialogRef.close(true); + } + + cancel(): void { + this.dialogRef.close(false); + } +} diff --git a/src/app/shopping-cart/shopping-cart.component.css b/src/app/cancellation-failed/cancellation-failed.component.css similarity index 100% rename from src/app/shopping-cart/shopping-cart.component.css rename to src/app/cancellation-failed/cancellation-failed.component.css diff --git a/src/app/cancellation-failed/cancellation-failed.component.html b/src/app/cancellation-failed/cancellation-failed.component.html new file mode 100644 index 0000000..773143e --- /dev/null +++ b/src/app/cancellation-failed/cancellation-failed.component.html @@ -0,0 +1,11 @@ +
+ + warning + +

Stornierung fehlgeschlagen!

+

{{ infoText }}

+ + + +
+ diff --git a/src/app/cancellation-failed/cancellation-failed.component.ts b/src/app/cancellation-failed/cancellation-failed.component.ts new file mode 100644 index 0000000..05663f0 --- /dev/null +++ b/src/app/cancellation-failed/cancellation-failed.component.ts @@ -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(false); + + retry = output(); + + 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.'; + } +} diff --git a/src/app/cancellation-success/cancellation-success.component.css b/src/app/cancellation-success/cancellation-success.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/cancellation-success/cancellation-success.component.html b/src/app/cancellation-success/cancellation-success.component.html new file mode 100644 index 0000000..166309e --- /dev/null +++ b/src/app/cancellation-success/cancellation-success.component.html @@ -0,0 +1,11 @@ +
+ + task_alt + +

Stornierung erfolgreich!

+

{{ infoText }}

+ + + + +
diff --git a/src/app/cancellation-success/cancellation-success.component.ts b/src/app/cancellation-success/cancellation-success.component.ts new file mode 100644 index 0000000..6110bed --- /dev/null +++ b/src/app/cancellation-success/cancellation-success.component.ts @@ -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(); + moreThanOne = input(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()}`; + } +} diff --git a/src/app/conversion-failed/conversion-failed.component.css b/src/app/conversion-failed/conversion-failed.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/conversion-failed/conversion-failed.component.html b/src/app/conversion-failed/conversion-failed.component.html new file mode 100644 index 0000000..018a3ad --- /dev/null +++ b/src/app/conversion-failed/conversion-failed.component.html @@ -0,0 +1,11 @@ +
+ + warning + +

Kauf fehlgeschlagen!

+

{{ infoText }}

+ + + +
+ diff --git a/src/app/conversion-failed/conversion-failed.component.ts b/src/app/conversion-failed/conversion-failed.component.ts new file mode 100644 index 0000000..3957541 --- /dev/null +++ b/src/app/conversion-failed/conversion-failed.component.ts @@ -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(false); + + retry = output(); + + 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.'; + } +} diff --git a/src/app/http.service.ts b/src/app/http.service.ts index b0ad392..60d0f7e 100644 --- a/src/app/http.service.ts +++ b/src/app/http.service.ts @@ -23,16 +23,6 @@ export class HttpService { /* Bestellung APIs */ - /* GET /api/bestellung/{id} */ - getAllOrder(id: number): Observable { - return this.http.get(`${this.baseUrl}bestellung`); - } - - /* GET /api/bestellung/{id} */ - getOrderById(id: number): Observable { - return this.http.get(`${this.baseUrl}bestellung/${id}`); - } - /* POST /api/bestellung/filter */ getOrdersByFilter(filter: string[]): Observable { return this.http.post(`${this.baseUrl}bestellung/filter`, filter); @@ -43,17 +33,11 @@ export class HttpService { return this.http.post(`${this.baseUrl}bestellung`, order); } - /* PUT /api/bestellung/{id} */ - updateOrder(id: number, order: Partial): Observable { - return this.http.put(`${this.baseUrl}bestellung/${id}`, order); + /* PUT /api/bestellung */ + updateOrder(order: Partial): Observable { + return this.http.put(`${this.baseUrl}bestellung`, order); } - /* DELETE /api/bestellung/{id} */ - deleteOrder(id: number): Observable { - return this.http.delete(`${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 { - return this.http.get(`${this.baseUrl}eintrittskarte`); - } - - /* GET /api/eintrittskarte/{id} */ - getTicketById(id: number): Observable { - return this.http.get(`${this.baseUrl}eintrittskarte/${id}`); - } - /* POST /api/eintrittskarte/filter */ getTicketsByFilter(filter: string[]): Observable { return this.http.post(`${this.baseUrl}eintrittskarte/filter`, filter); } - /* POST /api/eintrittskarte */ - addTicket(order: Omit): Observable { - return this.http.post(`${this.baseUrl}eintrittskarte`, order); - } - - /* PUT /api/eintrittskarte/{id} */ - updateTicket(id: number, order: Partial): Observable { - return this.http.put(`${this.baseUrl}eintrittskarte/${id}`, order); - } - - /* DELETE /api/eintrittskarte/{id} */ - deleteTicket(id: number): Observable { - return this.http.delete(`${this.baseUrl}eintrittskarte/${id}`); - } - /* Kinosaal APIs */ @@ -100,34 +59,14 @@ export class HttpService { return this.http.get(`${this.baseUrl}kinosaal`); } - /* GET /api/kinosaal/{id} */ - getKinosaalById(id: number): Observable { - return this.http.get(`${this.baseUrl}kinosaal/${id}`); - } - /* POST /api/kinosaal */ addKinosaal(kinosaal: Omit): Observable { return this.http.post(`${this.baseUrl}kinosaal`, kinosaal); } - /* PUT /api/kinosaal/{id} */ - updateKinosaal(id: number, kinosaal: Partial): Observable { - return this.http.put(`${this.baseUrl}kinosaal/${id}`, kinosaal); - } - - /* DELETE /api/kinosaal/{id} */ - deleteKinosaal(id: number): Observable { - return this.http.delete(`${this.baseUrl}kinosaal/${id}`); - } - /* Vorstellung APIs */ - /* GET /api/vorstellung */ - getAllPerformaces(): Observable { - return this.http.get(`${this.baseUrl}vorstellung`); - } - /* GET /api/vorstellung/{id} */ getPerformaceById(id: number): Observable { return this.http.get(`${this.baseUrl}vorstellung/${id}`); @@ -138,21 +77,6 @@ export class HttpService { return this.http.post(`${this.baseUrl}vorstellung/filter`, filter); } - /* POST /api/vorstellung */ - addPerformace(vorstellung: Omit): Observable { - return this.http.post(`${this.baseUrl}vorstellung`, vorstellung); - } - - /* PUT /api/vorstellung/{id} */ - updatePerformace(id: number, vorstellung: Partial): Observable { - return this.http.put(`${this.baseUrl}vorstellung/${id}`, vorstellung); - } - - /* DELETE /api/vorstellung/{id} */ - deletePerformace(id: number): Observable { - return this.http.delete(`${this.baseUrl}vorstellung/${id}`); - } - /* Film APIs */ diff --git a/src/app/loading.service.ts b/src/app/loading.service.ts index 988e652..532b309 100644 --- a/src/app/loading.service.ts +++ b/src/app/loading.service.ts @@ -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; + 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(); + } }); } diff --git a/src/app/movie-importer/movie-importer.component.html b/src/app/movie-importer/movie-importer.component.html index 6f614b8..59385ca 100644 --- a/src/app/movie-importer/movie-importer.component.html +++ b/src/app/movie-importer/movie-importer.component.html @@ -5,7 +5,7 @@
Film online suchen - + @if (formControl.hasError('noResults')) { Keine Suchergebnisse gefunden } diff --git a/src/app/movie-importer/movie-importer.component.ts b/src/app/movie-importer/movie-importer.component.ts index c68289b..2419b29 100644 --- a/src/app/movie-importer/movie-importer.component.ts +++ b/src/app/movie-importer/movie-importer.component.ts @@ -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(); } diff --git a/src/app/movie-performance/movie-performance.component.ts b/src/app/movie-performance/movie-performance.component.ts index 6f98ff9..217cec6 100644 --- a/src/app/movie-performance/movie-performance.component.ts +++ b/src/app/movie-performance/movie-performance.component.ts @@ -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(() => diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index a8659a7..1e38f32 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -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'}, ] diff --git a/src/app/order/order.component.css b/src/app/order/order.component.css index 367c809..9732f3d 100644 --- a/src/app/order/order.component.css +++ b/src/app/order/order.component.css @@ -1,3 +1,7 @@ +:host { + min-width: 500px; +} + mat-stepper { background: transparent !important; } diff --git a/src/app/order/order.component.html b/src/app/order/order.component.html index 7b5b71d..ab9e36b 100644 --- a/src/app/order/order.component.html +++ b/src/app/order/order.component.html @@ -27,43 +27,47 @@
} - - - Warenkorb + + + Warenkorb -
+
- -
- @for (seatCategory of seatCategories(); track $index) { -
- - } - @empty { - - } -
+ @if (selectedSeatsService.hadConflict()) { + + } - + +
+ @for (seatCategory of seatCategories(); track $index) { +
+ + } + @empty { + + } +
- -
-

- Tickets gesamt: -

-

- {{ getPriceDisplay(totalPrice()) }} -

-
+ - -
- - -
+ +
+

+ Tickets gesamt: +

+

+ {{ getPriceDisplay(totalPrice()) }} +

+
+ + +
+ + +
- +
Anschrift @@ -71,11 +75,11 @@ @if (isReservationSuccess() && !isSubmitting()) {
- + } - @else if (isReservationError() && !isSubmitting()) { + @else if (isReservationError() && !isSubmitting() && performance()) {
- + } @else { @@ -119,11 +123,23 @@ @if (isPurchaseSuccess() && !isSubmitting()) {
- + } - @else if (isPurchaseError() && !isSubmitting()) { + @else if (isPurchaseError() && !isSubmitting() && performance()) {
- + + } + @else if (isConversionError() && !isSubmitting()) { +
+ + } + @else if (isCancellationSuccess() && !isSubmitting() && performance()) { +
+ + } + @else if (isCancellationError() && !isSubmitting()) { +
+ } @else { @@ -179,9 +195,17 @@
- + + @if (isResuming()) { + + } @else { + + } + diff --git a/src/app/order/order.component.ts b/src/app/order/order.component.ts index 868029e..5ec96e5 100644 --- a/src/app/order/order.component.ts +++ b/src/app/order/order.component.ts @@ -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(); seatCategories = input.required(); + existingOrder = input(); + existingTickets = input(); + resumeWithCancel = input(true); + + stepChanged = output(); + 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(); + } } diff --git a/src/app/pay-for-order/pay-for-order.component.css b/src/app/pay-for-order/pay-for-order.component.css new file mode 100644 index 0000000..7292745 --- /dev/null +++ b/src/app/pay-for-order/pay-for-order.component.css @@ -0,0 +1,7 @@ +.middle { + position: relative; + top: 40%; + -webkit-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); +} diff --git a/src/app/pay-for-order/pay-for-order.component.html b/src/app/pay-for-order/pay-for-order.component.html new file mode 100644 index 0000000..dff3f0f --- /dev/null +++ b/src/app/pay-for-order/pay-for-order.component.html @@ -0,0 +1,40 @@ + + +
+ +
+ + Reservierungsnummer eingeben + + + @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 + } + + +
+ @if (formControl.valid || !formControl.touched) { +
+ } + + + +
diff --git a/src/app/pay-for-order/pay-for-order.component.ts b/src/app/pay-for-order/pay-for-order.component.ts new file mode 100644 index 0000000..3c2c06f --- /dev/null +++ b/src/app/pay-for-order/pay-for-order.component.ts @@ -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(); + } +} diff --git a/src/app/purchase-failed/purchase-failed.component.html b/src/app/purchase-failed/purchase-failed.component.html index d841ce0..b2a104a 100644 --- a/src/app/purchase-failed/purchase-failed.component.html +++ b/src/app/purchase-failed/purchase-failed.component.html @@ -3,9 +3,9 @@ warning

Kauf fehlgeschlagen!

-

Leider konnten Ihre Sitzplätze nicht gekauft werden. Dies kann passieren, wenn andere Nutzer zeitgleich versucht haben, dieselben Sitzplätze zu kaufen.

+

{{ infoText }}

- - + +
diff --git a/src/app/purchase-failed/purchase-failed.component.ts b/src/app/purchase-failed/purchase-failed.component.ts index 619d3e9..50c3db5 100644 --- a/src/app/purchase-failed/purchase-failed.component.ts +++ b/src/app/purchase-failed/purchase-failed.component.ts @@ -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(); + moreThanOne = input(false); + retry = output(); + + 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()}`; + } } diff --git a/src/app/purchase-success/purchase-success.component.html b/src/app/purchase-success/purchase-success.component.html index c439043..68777c2 100644 --- a/src/app/purchase-success/purchase-success.component.html +++ b/src/app/purchase-success/purchase-success.component.html @@ -1,11 +1,11 @@

Vielen Dank für Ihren Einkauf!

-

Ihre Sitzplätze wurden erfolgreich gebucht.

+

{{ infoText }}

- - + +
diff --git a/src/app/purchase-success/purchase-success.component.ts b/src/app/purchase-success/purchase-success.component.ts index 8dad591..3805ad4 100644 --- a/src/app/purchase-success/purchase-success.component.ts +++ b/src/app/purchase-success/purchase-success.component.ts @@ -9,4 +9,18 @@ import { Component, input } from '@angular/core'; }) export class PurchaseSuccessComponent { tickets = input.required(); + moreThanOne = input(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'; + } } diff --git a/src/app/reservation-failed/reservation-failed.component.html b/src/app/reservation-failed/reservation-failed.component.html index abd1532..8e89629 100644 --- a/src/app/reservation-failed/reservation-failed.component.html +++ b/src/app/reservation-failed/reservation-failed.component.html @@ -3,9 +3,9 @@ warning

Reservierung fehlgeschlagen!

-

Leider konnten Ihre Sitzplätze nicht reserviert werden. Dies kann passieren, wenn andere Nutzer gleichzeitig versucht haben, dieselben Sitzplätze zu reservieren.

+

{{ infoText }}

- - + + diff --git a/src/app/reservation-failed/reservation-failed.component.ts b/src/app/reservation-failed/reservation-failed.component.ts index 7693d1e..0018215 100644 --- a/src/app/reservation-failed/reservation-failed.component.ts +++ b/src/app/reservation-failed/reservation-failed.component.ts @@ -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(); + moreThanOne = input(false); + retry = output(); + + 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()}`; + } } diff --git a/src/app/reservation-success/reservation-success.component.html b/src/app/reservation-success/reservation-success.component.html index 8d28e59..fac0714 100644 --- a/src/app/reservation-success/reservation-success.component.html +++ b/src/app/reservation-success/reservation-success.component.html @@ -1,14 +1,14 @@

Reservierung erfolgreich!

-

Ihre Sitzplätze wurden erfolgreich reserviert. Bitte nennen sie den folgenden Code an der Kasse, um Ihre Reservierung in eine Buchung umzuwandeln.

+

{{ infoText }}

{{ order().code }}
- - -
+ + +
Reservierung stornieren
diff --git a/src/app/reservation-success/reservation-success.component.ts b/src/app/reservation-success/reservation-success.component.ts index 8754ae6..1efca8e 100644 --- a/src/app/reservation-success/reservation-success.component.ts +++ b/src/app/reservation-success/reservation-success.component.ts @@ -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(); + moreThanOne = input(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'; } } diff --git a/src/app/seat/seat.component.html b/src/app/seat/seat.component.html index 975c099..f4a1015 100644 --- a/src/app/seat/seat.component.html +++ b/src/app/seat/seat.component.html @@ -1,4 +1,4 @@ -