From 51f449ced028ad68cfea427d2e05ad96305a8b75 Mon Sep 17 00:00:00 2001 From: bisco Date: Wed, 29 Apr 2026 18:23:05 +0200 Subject: [PATCH] feat(frontend): add staff check-in flow --- .../check-in-placeholder-page.component.ts | 248 +++++++++++++++++- .../src/app/services/shows-api.service.ts | 26 ++ 2 files changed, 262 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/pages/check-in-placeholder-page.component.ts b/frontend/src/app/pages/check-in-placeholder-page.component.ts index 4a8264f..73cc24e 100644 --- a/frontend/src/app/pages/check-in-placeholder-page.component.ts +++ b/frontend/src/app/pages/check-in-placeholder-page.component.ts @@ -1,28 +1,113 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; + +import { CheckInPreviewResponse, ShowsApiService } from '../services/shows-api.service'; + +type UiState = + | 'idle' + | 'preview_loading' + | 'preview_success' + | 'confirm_loading' + | 'confirm_success' + | 'invalid_token' + | 'pending_reservation' + | 'already_checked_in' + | 'unauthorized' + | 'error'; @Component({ standalone: true, - imports: [MatCardModule, MatFormFieldModule, MatInputModule], + imports: [ + DatePipe, + ReactiveFormsModule, + RouterLink, + MatButtonModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatProgressSpinnerModule, + ], template: `
- Future scan/lookup input - - Opaque QR token - - +
+ + Opaque token + + @if (tokenForm.controls.token.touched && tokenForm.controls.token.hasError('required')) { + Token is required. + } + + +
+ + Home + Shows +
+
+ + @if (state() === 'preview_success' && previewData()) { +
+

Admission preview

+
+
Show
{{ previewData()!.show_title }}
+
Starts at
{{ previewData()!.starts_at | date: 'EEEE d MMMM, HH:mm' }}
+
Party size
{{ previewData()!.party_size }}
+
Reservation
#{{ previewData()!.reservation_id }}
+
+ +
+ } + + @if (state() === 'confirm_success') { +

Check-in confirmed successfully.

+ } + + @if (state() === 'invalid_token') { +

Invalid token.

+ } + @if (state() === 'pending_reservation') { +

Reservation is still pending confirmation.

+ } + @if (state() === 'already_checked_in') { +

This reservation is already checked in.

+ } + @if (state() === 'unauthorized') { +

You are not authorized. Staff login is required.

+ } + @if (state() === 'error') { +

Something went wrong. Please try again.

+ }
@@ -66,7 +151,146 @@ import { MatInputModule } from '@angular/material/input'; .full-width { width: 100%; } + + .actions { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + } + + button[mat-flat-button] { + min-width: 150px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + } + + .preview-panel { + margin-top: 18px; + border-top: 1px solid var(--azionelab-border); + padding-top: 16px; + } + + .preview-panel h2 { + margin: 0 0 12px; + font-size: 1.15rem; + } + + .preview-panel dl { + display: grid; + gap: 10px; + margin: 0 0 16px; + } + + .preview-panel dt { + font-size: 0.8rem; + text-transform: uppercase; + color: var(--azionelab-muted); + font-weight: 700; + } + + .preview-panel dd { + margin: 2px 0 0; + } + + .success-message { + margin: 16px 0 0; + color: #2e7d32; + font-weight: 600; + } + + .error-message { + margin: 16px 0 0; + color: #b3261e; + font-weight: 500; + } `], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CheckInPlaceholderPageComponent {} +export class CheckInPlaceholderPageComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly formBuilder = inject(FormBuilder); + private readonly showsApi = inject(ShowsApiService); + + protected readonly tokenForm = this.formBuilder.nonNullable.group({ + token: ['', [Validators.required]], + }); + + protected readonly state = signal('idle'); + protected readonly previewData = signal(null); + + protected preview(): void { + if (this.tokenForm.invalid) { + this.tokenForm.markAllAsTouched(); + return; + } + + const token = this.tokenForm.controls.token.value.trim(); + if (!token) { + this.tokenForm.controls.token.setErrors({ required: true }); + return; + } + + this.state.set('preview_loading'); + this.previewData.set(null); + + this.showsApi.previewCheckIn(token) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (response) => { + this.previewData.set(response); + this.state.set('preview_success'); + }, + error: (error: HttpErrorResponse) => this.setErrorState(error), + }); + } + + protected confirm(): void { + const token = this.tokenForm.controls.token.value.trim(); + if (!token) { + this.state.set('invalid_token'); + return; + } + + this.state.set('confirm_loading'); + + this.showsApi.confirmCheckIn(token) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.state.set('confirm_success'); + }, + error: (error: HttpErrorResponse) => this.setErrorState(error), + }); + } + + protected isBusy(): boolean { + return this.state() === 'preview_loading' || this.state() === 'confirm_loading'; + } + + private setErrorState(error: HttpErrorResponse): void { + if (error.status === 401 || error.status === 403) { + this.state.set('unauthorized'); + return; + } + + if (error.status === 404) { + this.state.set('invalid_token'); + return; + } + + if (error.status === 409 && error.error?.status === 'reservation_not_confirmed') { + this.state.set('pending_reservation'); + return; + } + + if (error.status === 409 && error.error?.status === 'already_checked_in') { + this.state.set('already_checked_in'); + return; + } + + this.state.set('error'); + } +} diff --git a/frontend/src/app/services/shows-api.service.ts b/frontend/src/app/services/shows-api.service.ts index 896a00e..b34d08c 100644 --- a/frontend/src/app/services/shows-api.service.ts +++ b/frontend/src/app/services/shows-api.service.ts @@ -52,6 +52,24 @@ export type ReservationConfirmResponse = { qr_code_image?: string; }; +export type CheckInPreviewResponse = { + status: 'valid'; + reservation_id: number; + performance_id: number; + show_title: string; + starts_at: string; + party_size: number; +}; + +export type CheckInConfirmResponse = { + status: 'checked_in'; + reservation_id: number; + performance_id: number; + party_size: number; + checked_in_at: string; + checked_in_by: number; +}; + type ShowListResponse = { results: ShowListItem[]; }; @@ -93,4 +111,12 @@ export class ShowsApiService { params: { token }, }); } + + previewCheckIn(token: string): Observable { + return this.http.post(`${this.apiBaseUrl}/check-ins/preview/`, { token }); + } + + confirmCheckIn(token: string): Observable { + return this.http.post(`${this.apiBaseUrl}/check-ins/confirm/`, { token }); + } }