generated from bisco/codex-bootstrap
Merge branch 'feature/frontend-booking' into develop
This commit is contained in:
@@ -10,7 +10,7 @@ export const appRoutes: Routes = [
|
|||||||
{ path: '', component: HomePageComponent, title: 'AzioneLab' },
|
{ path: '', component: HomePageComponent, title: 'AzioneLab' },
|
||||||
{ path: 'shows', component: ShowListPageComponent, title: 'Shows | AzioneLab' },
|
{ path: 'shows', component: ShowListPageComponent, title: 'Shows | AzioneLab' },
|
||||||
{ path: 'shows/:slug', component: ShowDetailPlaceholderPageComponent, title: 'Show detail | 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: 'check-in', component: CheckInPlaceholderPageComponent, title: 'Check-in | AzioneLab' },
|
||||||
{ path: '**', redirectTo: '' },
|
{ path: '**', redirectTo: '' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,42 +1,120 @@
|
|||||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
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 { MatCardModule } from '@angular/material/card';
|
||||||
import { MatDividerModule } from '@angular/material/divider';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatListModule } from '@angular/material/list';
|
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<string, string[]>;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [MatCardModule, MatDividerModule, MatListModule],
|
imports: [
|
||||||
|
ReactiveFormsModule,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
],
|
||||||
template: `
|
template: `
|
||||||
<section class="page">
|
<section class="page">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<p class="eyebrow">Booking</p>
|
<p class="eyebrow">Booking</p>
|
||||||
<h1>Performance {{ performanceId }}</h1>
|
<h1>Reserve seats</h1>
|
||||||
<p class="supporting">
|
<p class="supporting">Performance {{ performanceId }}. Complete the form and we will send a confirmation email.</p>
|
||||||
This page will host the reservation form and confirmation states backed by the existing booking APIs.
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<mat-card class="content-card">
|
<mat-card class="content-card">
|
||||||
<mat-card-title>Planned interactions</mat-card-title>
|
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<mat-list>
|
@if (isSuccess()) {
|
||||||
<mat-list-item>Load performance detail and availability</mat-list-item>
|
<div class="status-copy success" aria-live="polite">
|
||||||
<mat-list-item>Submit pending reservation</mat-list-item>
|
<mat-icon>check_circle</mat-icon>
|
||||||
<mat-list-item>Show email confirmation guidance</mat-list-item>
|
<div>
|
||||||
</mat-list>
|
<h2>Reservation created</h2>
|
||||||
|
<p>check your email</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<form [formGroup]="bookingForm" (ngSubmit)="submit()" novalidate>
|
||||||
|
<div class="form-grid">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Name</mat-label>
|
||||||
|
<input matInput type="text" formControlName="name" autocomplete="name" />
|
||||||
|
@if (bookingForm.controls.name.touched && bookingForm.controls.name.hasError('required')) {
|
||||||
|
<mat-error>Name is required.</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Email</mat-label>
|
||||||
|
<input matInput type="email" formControlName="email" autocomplete="email" />
|
||||||
|
@if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('required')) {
|
||||||
|
<mat-error>Email is required.</mat-error>
|
||||||
|
}
|
||||||
|
@if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('email')) {
|
||||||
|
<mat-error>Enter a valid email address.</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Number of seats</mat-label>
|
||||||
|
<input matInput type="number" min="1" step="1" formControlName="partySize" />
|
||||||
|
@if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('required')) {
|
||||||
|
<mat-error>Number of seats is required.</mat-error>
|
||||||
|
}
|
||||||
|
@if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('min')) {
|
||||||
|
<mat-error>At least 1 seat is required.</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (submitError()) {
|
||||||
|
<p class="error-message" aria-live="assertive">{{ submitError() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (fieldErrors().length > 0) {
|
||||||
|
<div class="field-errors" aria-live="assertive">
|
||||||
|
@for (message of fieldErrors(); track message) {
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button mat-flat-button type="submit" [disabled]="isSubmitting()">
|
||||||
|
@if (isSubmitting()) {
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
|
||||||
|
<span>Submitting...</span>
|
||||||
|
} @else {
|
||||||
|
<span>Reserve</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<a mat-button routerLink="/shows">Back to shows</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</section>
|
</section>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
.page {
|
.page {
|
||||||
max-width: 900px;
|
max-width: 760px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
margin-bottom: 22px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
@@ -55,7 +133,8 @@ import { MatListModule } from '@angular/material/list';
|
|||||||
.supporting {
|
.supporting {
|
||||||
color: var(--azionelab-muted);
|
color: var(--azionelab-muted);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
max-width: 50ch;
|
max-width: 56ch;
|
||||||
|
margin: 14px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-card {
|
.content-card {
|
||||||
@@ -64,11 +143,125 @@ import { MatListModule } from '@angular/material/list';
|
|||||||
background: var(--azionelab-surface);
|
background: var(--azionelab-surface);
|
||||||
box-shadow: var(--azionelab-shadow);
|
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,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class BookingPlaceholderPageComponent {
|
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<string[]>([]);
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
|
|||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
<mat-card-actions>
|
<mat-card-actions>
|
||||||
@if (performance.booking_enabled) {
|
@if (performance.booking_enabled) {
|
||||||
<a mat-flat-button [routerLink]="['/book', performance.id]">Book this performance</a>
|
<a mat-flat-button [routerLink]="['/performances', performance.id, 'book']">Book this performance</a>
|
||||||
} @else {
|
} @else {
|
||||||
<button mat-stroked-button type="button" disabled>Booking unavailable</button>
|
<button mat-stroked-button type="button" disabled>Booking unavailable</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ export type ShowDetail = ShowListItem & {
|
|||||||
performances?: ShowPerformance[];
|
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 = {
|
type ShowListResponse = {
|
||||||
results: ShowListItem[];
|
results: ShowListItem[];
|
||||||
};
|
};
|
||||||
@@ -58,4 +72,11 @@ export class ShowsApiService {
|
|||||||
params: { show: slug },
|
params: { show: slug },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createReservation(performanceId: string, payload: ReservationCreatePayload): Observable<ReservationCreateResponse> {
|
||||||
|
return this.http.post<ReservationCreateResponse>(
|
||||||
|
`${this.apiBaseUrl}/performances/${performanceId}/reservations/`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user