generated from bisco/codex-bootstrap
443 lines
14 KiB
TypeScript
443 lines
14 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">Prenotazione</p>
|
|
<h1>Richiedi i tuoi posti con calma</h1>
|
|
<p class="supporting">
|
|
Replica {{ performanceId }}. Compila il modulo con i dati essenziali: ti invieremo un'email per confermare la richiesta prima che i posti vengano assegnati in modo definitivo.
|
|
</p>
|
|
</header>
|
|
|
|
<div class="booking-grid">
|
|
<mat-card class="summary-card">
|
|
<mat-card-content>
|
|
<p class="summary-label">Come funziona</p>
|
|
<h2>Ti chiediamo pochi dati e ti accompagniamo fino alla conferma.</h2>
|
|
<ul class="summary-list">
|
|
<li>Riceverai un link di conferma all'indirizzo email che inserisci.</li>
|
|
<li>La disponibilita' viene controllata prima della conferma finale.</li>
|
|
<li>Dopo la conferma avrai il tuo QR code per l'ingresso.</li>
|
|
</ul>
|
|
</mat-card-content>
|
|
</mat-card>
|
|
|
|
<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>La tua richiesta e' partita</h2>
|
|
<p>Controlla la tua email: con un ultimo passaggio potrai confermare la prenotazione e ricevere il QR code per l'ingresso.</p>
|
|
<div class="status-steps">
|
|
<span><mat-icon fontSet="material-symbols-outlined">mail</mat-icon> Apri l'email che ti abbiamo inviato</span>
|
|
<span><mat-icon fontSet="material-symbols-outlined">verified</mat-icon> Conferma i posti con un tocco</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>Ti chiediamo solo il necessario. La conferma via email ci aiuta a tenere la disponibilita' chiara per tutti.</p>
|
|
</div>
|
|
|
|
<div class="form-grid">
|
|
<mat-form-field appearance="outline">
|
|
<mat-icon matPrefix fontSet="material-symbols-outlined">person</mat-icon>
|
|
<mat-label>Nome</mat-label>
|
|
<input matInput type="text" formControlName="name" autocomplete="name" />
|
|
@if (bookingForm.controls.name.touched && bookingForm.controls.name.hasError('required')) {
|
|
<mat-error>Il nome e' obbligatorio.</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>Qui arrivera' il link per confermare la tua richiesta.</mat-hint>
|
|
@if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('required')) {
|
|
<mat-error>L'email e' obbligatoria.</mat-error>
|
|
}
|
|
@if (bookingForm.controls.email.touched && bookingForm.controls.email.hasError('email')) {
|
|
<mat-error>Inserisci un indirizzo email valido.</mat-error>
|
|
}
|
|
</mat-form-field>
|
|
|
|
<mat-form-field appearance="outline">
|
|
<mat-icon matPrefix fontSet="material-symbols-outlined">group</mat-icon>
|
|
<mat-label>Numero di posti</mat-label>
|
|
<input matInput type="number" min="1" step="1" formControlName="partySize" />
|
|
<mat-hint>Indica quante persone desideri includere nella prenotazione.</mat-hint>
|
|
@if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('required')) {
|
|
<mat-error>Il numero di posti e' obbligatorio.</mat-error>
|
|
}
|
|
@if (bookingForm.controls.partySize.touched && bookingForm.controls.partySize.hasError('min')) {
|
|
<mat-error>Devi richiedere almeno 1 posto.</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">Controlla i dati evidenziati:</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>Invio in corso...</span>
|
|
} @else {
|
|
<ng-container>
|
|
<mat-icon fontSet="material-symbols-outlined">confirmation_number</mat-icon>
|
|
<span>Prenota</span>
|
|
</ng-container>
|
|
}
|
|
</button>
|
|
<a mat-button routerLink="/shows">Torna agli spettacoli</a>
|
|
</div>
|
|
</form>
|
|
}
|
|
</mat-card-content>
|
|
</mat-card>
|
|
</div>
|
|
</section>
|
|
`,
|
|
styles: [`
|
|
.page-header {
|
|
margin-bottom: 26px;
|
|
}
|
|
|
|
.supporting {
|
|
max-width: 56ch;
|
|
margin: 14px 0 0;
|
|
}
|
|
|
|
.booking-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
|
|
gap: 20px;
|
|
align-items: start;
|
|
}
|
|
|
|
.summary-card,
|
|
.content-card {
|
|
border-radius: var(--azionelab-radius-lg);
|
|
border: 1px solid var(--azionelab-border);
|
|
background: var(--azionelab-surface-strong);
|
|
box-shadow: var(--azionelab-shadow);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.summary-card {
|
|
background:
|
|
linear-gradient(180deg, rgba(255, 252, 248, 0.98), rgba(247, 238, 227, 0.94));
|
|
}
|
|
|
|
mat-card-content {
|
|
padding: 28px !important;
|
|
}
|
|
|
|
.summary-label {
|
|
margin: 0 0 10px;
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--azionelab-accent);
|
|
}
|
|
|
|
.summary-card h2 {
|
|
margin: 0;
|
|
max-width: 14ch;
|
|
}
|
|
|
|
.summary-list {
|
|
display: grid;
|
|
gap: 12px;
|
|
margin: 18px 0 0;
|
|
padding-left: 18px;
|
|
color: var(--azionelab-ink-soft);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.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) {
|
|
.booking-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
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('Non siamo riusciti a inviare la richiesta in questo momento. Riprova tra poco.');
|
|
},
|
|
});
|
|
}
|
|
|
|
private flattenValidationErrors(errors: ApiValidationErrors): string[] {
|
|
return Object.entries(errors).flatMap(([field, messages]) => {
|
|
const labelMap: Record<string, string> = {
|
|
name: 'nome',
|
|
email: 'email',
|
|
party_size: 'numero di posti',
|
|
};
|
|
const label = labelMap[field] ?? field;
|
|
return messages.map((message) => `${label}: ${this.translateValidationMessage(message)}`);
|
|
});
|
|
}
|
|
|
|
private translateValidationMessage(message: string): string {
|
|
const translations: Record<string, string> = {
|
|
'This field is required.': 'questo campo e\' obbligatorio.',
|
|
'Enter a valid email address.': 'inserisci un indirizzo email valido.',
|
|
'Ensure this value is greater than or equal to 1.': 'inserisci un valore maggiore o uguale a 1.',
|
|
};
|
|
|
|
return translations[message] ?? message;
|
|
}
|
|
}
|