diff --git a/src/app/order/order.component.html b/src/app/order/order.component.html index ad0a5bc..7b5b71d 100644 --- a/src/app/order/order.component.html +++ b/src/app/order/order.component.html @@ -1,195 +1,195 @@
-@if (!performance() && (loadingService.loading$ | async)){ -
- -
-} -@else if (performance()) { - -
- -
- - @if(isSubmitting) { -
+ @if (!performance() && (loadingService.loading$ | async)){ +
} + @else if (performance()) { - - - Warenkorb +
+ +
-
+ @if(isSubmitting()) { +
+ +
+ } - -
- @for (seatCategory of seatCategories(); track $index) { -
- - } - @empty { - - } -
+ + + Warenkorb - +
- -
-

- Tickets gesamt: -

-

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

-
- - -
- - -
-
- - -
- Anschrift - -
- - @if (seatsReserved && !isSubmitting) { -
- @if (successful) { - - } @else { - - } - } - @else { - - - - Name - - @if (fData['name'].hasError('minlength')) { Mindestens 3 Zeichen } - - - - - E-Mail Adresse - - @if (fData['email'].hasError('email')) { Ungültige E-Mail-Adresse } - - - -
- - Ich akzeptiere die AGB und die Datenbestimmung - + +
+ @for (seatCategory of seatCategories(); track $index) { +
+ + } + @empty { + + }
- -
- - -
+ - } - - - - -
- Zahlung - -
- - @if (seatsPurchased && !isSubmitting) { -
- @if (successful) { - - } @else { - - } - } - @else { - - - - Kartennummer - - @if (fPayment['cardNumber'].hasError('pattern')) { Ungültige Kartennummer } - - - - - - Name des Besitzers - - @if (fPayment['cardName'].hasError('minlength')) { Mindestens 3 Zeichen } - - - -
- - Gültig bis (MM/YY) - - @if (fPayment['expiry'].hasError('pattern')) { Ungültiges Format } - - - - CVV - - @if (fPayment['cvv'].hasError('pattern')) { 3–4 Ziffernt } - -
- - -
- - encrypted - -

- Ihre Zahlung wird sicher über unsere Partner verarbeitet.
Wir speichern keine Zahlungsinformationen. + +

+

+ Tickets gesamt: +

+

+ {{ getPriceDisplay(totalPrice()) }}

-
- - +
+ +
- } - - + - -} + +
+ Anschrift + +
+ + @if (isReservationSuccess() && !isSubmitting()) { +
+ + } + @else if (isReservationError() && !isSubmitting()) { +
+ + } + @else { + + + + Name + + @if (fData['name'].hasError('minlength')) { Mindestens 3 Zeichen } + + + + + E-Mail Adresse + + @if (fData['email'].hasError('email')) { Ungültige E-Mail-Adresse } + + + +
+ + Ich akzeptiere die AGB und die Datenbestimmung + +
+ + +
+ + +
+ + } + +
+
+ + +
+ Zahlung + +
+ + @if (isPurchaseSuccess() && !isSubmitting()) { +
+ + } + @else if (isPurchaseError() && !isSubmitting()) { +
+ + } + @else { + + + + Kartennummer + + @if (fPayment['cardNumber'].hasError('pattern')) { Ungültige Kartennummer } + + + + + Name des Besitzers + + @if (fPayment['cardName'].hasError('minlength')) { Mindestens 3 Zeichen } + + + +
+ + Gültig bis (MM/YY) + + @if (fPayment['expiry'].hasError('pattern')) { Ungültiges Format } + + + + CVV + + @if (fPayment['cvv'].hasError('pattern')) { 3–4 Ziffernt } + +
+ + +
+ + encrypted + +

+ Ihre Zahlung wird sicher über unsere Partner verarbeitet.
Wir speichern keine Zahlungsinformationen. +

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

Kauf fehlgeschlagen!

-

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

+

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

diff --git a/src/app/reservation-failed/reservation-failed.component.html b/src/app/reservation-failed/reservation-failed.component.html index 792a731..a8913c0 100644 --- a/src/app/reservation-failed/reservation-failed.component.html +++ b/src/app/reservation-failed/reservation-failed.component.html @@ -3,7 +3,7 @@ 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.

+

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

diff --git a/src/app/seat-selection/seat-selection.component.ts b/src/app/seat-selection/seat-selection.component.ts index 9c5ca14..6bab062 100644 --- a/src/app/seat-selection/seat-selection.component.ts +++ b/src/app/seat-selection/seat-selection.component.ts @@ -14,7 +14,7 @@ export class SeatSelectionComponent { SelectedSeatsService = inject(SelectedSeatsService); selectedSeatsByCategory = computed(() => - this.SelectedSeatsService.getSelectedSeatsByCategory(this.seatCategory().id).length + this.SelectedSeatsService.getSeatsByCategory(this.seatCategory().id).length ); totalCategoryPrice = computed(() => diff --git a/src/app/seat/seat.component.ts b/src/app/seat/seat.component.ts index 5b48e2b..1a8dd58 100644 --- a/src/app/seat/seat.component.ts +++ b/src/app/seat/seat.component.ts @@ -20,7 +20,7 @@ export class SeatComponent{ getSeatStateColor(): string { if (this.isSelectedAndAvaliable()) { - return this.seatService.getCommited()? '#00c951' : '#6366f1'; + return this.seatService.committed()? '#00c951' : '#6366f1'; } if (!this.seatService.getSeatIsSelectable()) { @@ -29,9 +29,9 @@ export class SeatComponent{ switch (this.state()) { case TheaterSeatState.RESERVED: - return this.seatService.getDebug()? '#f7e8c3' : '#c0c0c0'; + return this.seatService.debug()? '#f7e8c3' : '#c0c0c0'; case TheaterSeatState.BOOKED: - return this.seatService.getDebug()? '#ffc9c9' : '#c0c0c0'; + return this.seatService.debug()? '#ffc9c9' : '#c0c0c0'; default: case TheaterSeatState.AVAILABLE: return '#1B1B23'; diff --git a/src/app/selected-seats.service.ts b/src/app/selected-seats.service.ts index 07eb3a3..18a84ff 100644 --- a/src/app/selected-seats.service.ts +++ b/src/app/selected-seats.service.ts @@ -1,69 +1,61 @@ -import { Injectable, signal } from '@angular/core'; +import { computed, Injectable, signal } from '@angular/core'; import {Sitzplatz} from '@infinimotion/model-frontend'; @Injectable({ providedIn: 'root', }) export class SelectedSeatsService { - private selectedSeatsSignal = signal([]); - private seatIsSelectable: boolean = true; - private commited = false; - private debug = false; - get selectedSeats() { - return this.selectedSeatsSignal; - } + private selectedSeatsSignal = signal([]); + private seatIsSelectableSignal = signal(true); + private committedSignal = signal(false); + private debugSignal = signal(false); + + readonly selectedSeats = this.selectedSeatsSignal.asReadonly(); + readonly seatIsSelectable = this.seatIsSelectableSignal.asReadonly(); + readonly committed = this.committedSignal.asReadonly(); + readonly debug = this.debugSignal.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]); } removeSelectedSeat(selectedSeat: Sitzplatz): void { - this.selectedSeatsSignal.update(seats => - seats.filter(seat => seat.id !== selectedSeat.id) - ); + this.selectedSeatsSignal.update(seats => seats.filter(seat => seat.id !== selectedSeat.id)); } - getSelectedSeatsList(): Sitzplatz[] { - return this.selectedSeatsSignal(); + getSeatsByCategory(categoryId: number): Sitzplatz[] { + return this.selectedSeats().filter(seat => seat.row.category.id === categoryId); } - getSelectedSeatsByCategory(categoryId: number): Sitzplatz[] { - return this.selectedSeatsSignal().filter(seat => seat.row.category.id === categoryId); - } - - clearSelectedSeatsList(): void { + clearSelection(): void { this.selectedSeatsSignal.set([]); - this.commited = false; + this.committedSignal.set(false); } getSeatIsSelectable(): boolean{ - return this.seatIsSelectable; + return this.seatIsSelectable(); } - setSeatIsSelectableTrue(): void { - this.seatIsSelectable = true; - this.commited = false; + setSeatSelectable(selectable: boolean): void { + this.seatIsSelectableSignal.set(selectable); + if (selectable) { + this.committedSignal.set(false); + } } - setSeatIsSelectableFalse(): void { - this.seatIsSelectable = false; - } - - getCommited(): boolean { - return this.commited; - } - - setCommitedTrue(): void { - this.commited = true; - } - - getDebug(): boolean { - return this.debug; + commit(): void { + this.committedSignal.set(true); } toggleDebug(): void { - this.debug = !this.debug; + this.debugSignal.update(debug => !debug); } + isSeatSelected(seatId: number): boolean { + return this.selectedSeats().some(seat => seat.id === seatId); + } } diff --git a/src/app/theater-overlay/theater-overlay.component.ts b/src/app/theater-overlay/theater-overlay.component.ts index dce8dcc..9050e31 100644 --- a/src/app/theater-overlay/theater-overlay.component.ts +++ b/src/app/theater-overlay/theater-overlay.component.ts @@ -26,8 +26,8 @@ export class TheaterOverlayComponent implements OnInit { ngOnInit() { this.showId = Number(this.route.snapshot.paramMap.get('id')!); - this.selectedSeatService.clearSelectedSeatsList(); - this.selectedSeatService.setSeatIsSelectableTrue(); + this.selectedSeatService.clearSelection(); + this.selectedSeatService.setSeatSelectable(true); this.loadPerformanceAndSeats(); }