From 144c48c02fc4ec2cb5a073678ab90538b13010a4 Mon Sep 17 00:00:00 2001 From: bisco Date: Wed, 29 Apr 2026 18:14:07 +0200 Subject: [PATCH] feat(frontend): add performance booking form --- frontend/src/app/app.routes.ts | 2 +- .../booking-placeholder-page.component.ts | 233 ++++++++++++++++-- .../show-detail-placeholder-page.component.ts | 2 +- .../src/app/services/shows-api.service.ts | 21 ++ 4 files changed, 236 insertions(+), 22 deletions(-) diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 61e8a7d..5858c48 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -10,7 +10,7 @@ export const appRoutes: Routes = [ { path: '', component: HomePageComponent, title: 'AzioneLab' }, { path: 'shows', component: ShowListPageComponent, title: 'Shows | AzioneLab' }, { path: 'shows/:slug', component: ShowDetailPlaceholderPageComponent, title: 'Show detail | AzioneLab' }, - { path: 'book/:performanceId', component: BookingPlaceholderPageComponent, title: 'Book | AzioneLab' }, + { path: 'performances/:id/book', component: BookingPlaceholderPageComponent, title: 'Book | AzioneLab' }, { path: 'check-in', component: CheckInPlaceholderPageComponent, title: 'Check-in | AzioneLab' }, { path: '**', redirectTo: '' }, ]; diff --git a/frontend/src/app/pages/booking-placeholder-page.component.ts b/frontend/src/app/pages/booking-placeholder-page.component.ts index 272755a..6755bda 100644 --- a/frontend/src/app/pages/booking-placeholder-page.component.ts +++ b/frontend/src/app/pages/booking-placeholder-page.component.ts @@ -1,42 +1,120 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatListModule } from '@angular/material/list'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; + +import { ReservationCreatePayload, ShowsApiService } from '../services/shows-api.service'; + +type ApiValidationErrors = Record; @Component({ standalone: true, - imports: [MatCardModule, MatDividerModule, MatListModule], + imports: [ + ReactiveFormsModule, + RouterLink, + MatButtonModule, + MatCardModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + ], template: `
- Planned interactions - - Load performance detail and availability - Submit pending reservation - Show email confirmation guidance - + @if (isSuccess()) { +
+ check_circle +
+

Reservation created

+

check your email

+
+
+ } @else { +
+
+ + Name + + @if (bookingForm.controls.name.touched && bookingForm.controls.name.hasError('required')) { + Name is required. + } + + + + Email + + @if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('required')) { + Email is required. + } + @if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('email')) { + Enter a valid email address. + } + + + + Number of seats + + @if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('required')) { + Number of seats is required. + } + @if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('min')) { + At least 1 seat is required. + } + +
+ + @if (submitError()) { +

{{ submitError() }}

+ } + + @if (fieldErrors().length > 0) { +
+ @for (message of fieldErrors(); track message) { +

{{ message }}

+ } +
+ } + +
+ + Back to shows +
+
+ }
`, styles: [` .page { - max-width: 900px; + max-width: 760px; margin: 0 auto; } .page-header { - margin-bottom: 22px; + margin-bottom: 24px; } .eyebrow { @@ -55,7 +133,8 @@ import { MatListModule } from '@angular/material/list'; .supporting { color: var(--azionelab-muted); line-height: 1.6; - max-width: 50ch; + max-width: 56ch; + margin: 14px 0 0; } .content-card { @@ -64,11 +143,125 @@ import { MatListModule } from '@angular/material/list'; background: var(--azionelab-surface); box-shadow: var(--azionelab-shadow); } + + .form-grid { + display: grid; + gap: 14px; + } + + mat-form-field { + width: 100%; + } + + .error-message, + .field-errors p { + margin: 0; + color: #b3261e; + line-height: 1.4; + font-size: 0.92rem; + } + + .field-errors { + display: grid; + gap: 6px; + margin-top: 10px; + } + + .actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 18px; + flex-wrap: wrap; + } + + .actions button[mat-flat-button] { + min-width: 130px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + } + + .status-copy { + display: flex; + align-items: flex-start; + gap: 14px; + } + + .status-copy h2 { + margin: 0 0 6px; + } + + .status-copy p { + margin: 0; + color: var(--azionelab-muted); + } + + .status-copy.success mat-icon { + color: #2e7d32; + } `], changeDetection: ChangeDetectionStrategy.OnPush, }) export class BookingPlaceholderPageComponent { - protected readonly performanceId = this.route.snapshot.paramMap.get('performanceId') ?? '0'; + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly route = inject(ActivatedRoute); + private readonly showsApi = inject(ShowsApiService); - constructor(private readonly route: ActivatedRoute) {} + protected readonly performanceId = this.route.snapshot.paramMap.get('id') ?? ''; + protected readonly isSubmitting = signal(false); + protected readonly isSuccess = signal(false); + protected readonly submitError = signal(''); + protected readonly fieldErrors = signal([]); + + protected readonly bookingForm = this.formBuilder.nonNullable.group({ + name: ['', [Validators.required, Validators.maxLength(200)]], + email: ['', [Validators.required, Validators.email]], + partySize: [1, [Validators.required, Validators.min(1)]], + }); + + protected submit(): void { + this.submitError.set(''); + this.fieldErrors.set([]); + + if (this.bookingForm.invalid) { + this.bookingForm.markAllAsTouched(); + return; + } + + const payload: ReservationCreatePayload = { + name: this.bookingForm.controls.name.value.trim(), + email: this.bookingForm.controls.email.value.trim(), + party_size: this.bookingForm.controls.partySize.value, + }; + + this.isSubmitting.set(true); + + this.showsApi.createReservation(this.performanceId, payload) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.isSubmitting.set(false); + this.isSuccess.set(true); + this.bookingForm.disable(); + }, + error: (error: HttpErrorResponse) => { + this.isSubmitting.set(false); + if (error.status === 400 && error.error && typeof error.error === 'object') { + this.fieldErrors.set(this.flattenValidationErrors(error.error as ApiValidationErrors)); + return; + } + this.submitError.set('Could not create reservation. Please try again.'); + }, + }); + } + + private flattenValidationErrors(errors: ApiValidationErrors): string[] { + return Object.entries(errors).flatMap(([field, messages]) => { + const label = field === 'party_size' ? 'number of seats' : field; + return messages.map((message) => `${label}: ${message}`); + }); + } } diff --git a/frontend/src/app/pages/show-detail-placeholder-page.component.ts b/frontend/src/app/pages/show-detail-placeholder-page.component.ts index a32635e..9949a00 100644 --- a/frontend/src/app/pages/show-detail-placeholder-page.component.ts +++ b/frontend/src/app/pages/show-detail-placeholder-page.component.ts @@ -95,7 +95,7 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows- @if (performance.booking_enabled) { - Book this performance + Book this performance } @else { } diff --git a/frontend/src/app/services/shows-api.service.ts b/frontend/src/app/services/shows-api.service.ts index b756894..c750831 100644 --- a/frontend/src/app/services/shows-api.service.ts +++ b/frontend/src/app/services/shows-api.service.ts @@ -30,6 +30,20 @@ export type ShowDetail = ShowListItem & { performances?: ShowPerformance[]; }; +export type ReservationCreatePayload = { + name: string; + email: string; + party_size: number; +}; + +export type ReservationCreateResponse = { + id: number; + status: string; + performance: number; + party_size: number; + message: string; +}; + type ShowListResponse = { results: ShowListItem[]; }; @@ -58,4 +72,11 @@ export class ShowsApiService { params: { show: slug }, }); } + + createReservation(performanceId: string, payload: ReservationCreatePayload): Observable { + return this.http.post( + `${this.apiBaseUrl}/performances/${performanceId}/reservations/`, + payload, + ); + } }