Files
azionelab/frontend/src/app/pages/booking-placeholder-page.component.ts
T
2026-04-30 00:59:43 +02:00

394 lines
12 KiB
TypeScript

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 { 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<string, string[]>;
@Component({
standalone: true,
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
],
template: `
<section class="page">
<header class="page-header">
<p class="eyebrow">Booking</p>
<h1>Reserve seats</h1>
<p class="supporting">
Performance {{ performanceId }}. Complete the form and we will email a confirmation link before any reservation becomes active.
</p>
</header>
<mat-card class="content-card">
<mat-card-content>
@if (isSuccess()) {
<div class="status-panel success" aria-live="polite">
<div class="status-icon">
<mat-icon fontSet="material-symbols-outlined">mark_email_read</mat-icon>
</div>
<div>
<h2>Reservation created</h2>
<p>Check your email to confirm the booking and unlock the QR code for admission.</p>
<div class="status-steps">
<span><mat-icon fontSet="material-symbols-outlined">mail</mat-icon> Open the confirmation email</span>
<span><mat-icon fontSet="material-symbols-outlined">verified</mat-icon> Confirm your reservation</span>
</div>
</div>
</div>
} @else {
<form [formGroup]="bookingForm" (ngSubmit)="submit()" novalidate>
<div class="intro-note">
<mat-icon fontSet="material-symbols-outlined">info</mat-icon>
<p>We only ask for the essentials. Your seats are held only after email confirmation.</p>
</div>
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-icon matPrefix fontSet="material-symbols-outlined">person</mat-icon>
<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-icon matPrefix fontSet="material-symbols-outlined">mail</mat-icon>
<mat-label>Email</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" />
<mat-hint>We will send the confirmation link here.</mat-hint>
@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-icon matPrefix fontSet="material-symbols-outlined">group</mat-icon>
<mat-label>Number of seats</mat-label>
<input matInput type="number" min="1" step="1" formControlName="partySize" />
<mat-hint>Enter the total number of guests in your party.</mat-hint>
@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()) {
<div class="message-panel error" aria-live="assertive">
<mat-icon fontSet="material-symbols-outlined">error</mat-icon>
<p>{{ submitError() }}</p>
</div>
}
@if (fieldErrors().length > 0) {
<div class="message-panel error field-errors" aria-live="assertive">
<mat-icon fontSet="material-symbols-outlined">warning</mat-icon>
<div>
<p class="message-title">Please check the highlighted details:</p>
@for (message of fieldErrors(); track message) {
<p>{{ message }}</p>
}
</div>
</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 {
<ng-container>
<mat-icon fontSet="material-symbols-outlined">confirmation_number</mat-icon>
<span>Reserve</span>
</ng-container>
}
</button>
<a mat-button routerLink="/shows">Back to shows</a>
</div>
</form>
}
</mat-card-content>
</mat-card>
</section>
`,
styles: [`
.page {
max-width: 760px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
text-transform: uppercase;
font-size: 0.78rem;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
}
.supporting {
color: var(--azionelab-muted);
line-height: 1.6;
max-width: 56ch;
margin: 14px 0 0;
}
.content-card {
border-radius: 20px;
border: 1px solid var(--azionelab-border);
background: var(--azionelab-surface-strong);
box-shadow: var(--azionelab-shadow);
overflow: hidden;
}
mat-card-content {
padding: 28px !important;
}
.intro-note {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 20px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(159, 47, 40, 0.06);
color: var(--azionelab-muted);
}
.intro-note p {
margin: 0;
line-height: 1.55;
}
.intro-note mat-icon {
color: var(--azionelab-accent);
}
.form-grid {
display: grid;
gap: 14px;
}
mat-form-field {
width: 100%;
}
.message-panel {
display: flex;
align-items: flex-start;
gap: 12px;
margin-top: 14px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid transparent;
}
.message-panel.error {
background: var(--azionelab-error-bg);
border-color: var(--azionelab-error-border);
color: var(--azionelab-error-ink);
}
.message-panel p,
.field-errors p {
margin: 0;
line-height: 1.4;
font-size: 0.92rem;
}
.message-title {
font-weight: 700;
margin-bottom: 6px !important;
}
.field-errors > div {
display: grid;
gap: 6px;
}
.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-panel {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 4px 0;
}
.status-panel h2 {
margin: 0 0 6px;
}
.status-panel p {
margin: 0;
color: var(--azionelab-muted);
line-height: 1.55;
}
.status-panel.success {
padding: 22px;
border-radius: 18px;
background: var(--azionelab-success-bg);
border: 1px solid var(--azionelab-success-border);
}
.status-icon {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 16px;
background: rgba(46, 125, 50, 0.12);
}
.status-icon mat-icon {
color: var(--azionelab-success-ink);
}
.status-steps {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.status-steps span {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
color: var(--azionelab-success-ink);
font-size: 0.92rem;
}
.status-steps mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
@media (max-width: 640px) {
mat-card-content {
padding: 22px !important;
}
.status-panel,
.message-panel,
.intro-note {
border-radius: 14px;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BookingPlaceholderPageComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly showsApi = inject(ShowsApiService);
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}`);
});
}
}