feat(operations): improve reservation and check-in flows

This commit is contained in:
bisco
2026-04-29 19:23:42 +02:00
parent 6c5b5d99bc
commit 5cad1871e7
10 changed files with 627 additions and 15 deletions

View File

@@ -1,6 +1,40 @@
from django.contrib import admin from django import forms
from django.contrib import admin, messages
from .models import Reservation, ReservationToken from .models import Reservation, ReservationToken
from .services import PerformanceNotAvailable, create_pending_reservation
class ReservationAdminForm(forms.ModelForm):
class Meta:
model = Reservation
fields = ("performance", "name", "email", "phone", "party_size", "notes")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["performance"].help_text = (
"Choose the exact performance. A pending reservation and confirmation email will be created."
)
self.fields["party_size"].help_text = "Seats requested for this guest or group."
self.fields["notes"].help_text = "Optional internal note for staff."
def clean(self):
cleaned_data = super().clean()
performance = cleaned_data.get("performance")
party_size = cleaned_data.get("party_size")
if performance and not performance.is_booking_enabled:
self.add_error("performance", "Booking is currently disabled for this performance.")
if performance and party_size:
available_seats = performance.available_seats()
if party_size > available_seats:
self.add_error(
"party_size",
f"Only {available_seats} seats are currently available for this performance.",
)
return cleaned_data
class ReservationTokenInline(admin.TabularInline): class ReservationTokenInline(admin.TabularInline):
@@ -13,23 +47,187 @@ class ReservationTokenInline(admin.TabularInline):
@admin.register(Reservation) @admin.register(Reservation)
class ReservationAdmin(admin.ModelAdmin): class ReservationAdmin(admin.ModelAdmin):
form = ReservationAdminForm
list_display = ( list_display = (
"show_title",
"performance_starts_at",
"venue_name",
"name", "name",
"email", "email",
"performance",
"party_size", "party_size",
"status", "status",
"confirmation_state_display",
"check_in_state_display",
)
list_filter = (
"status",
"performance__show",
"performance__venue",
"performance__starts_at",
"confirmed_at", "confirmed_at",
"qr_code_generated_at", "qr_code_generated_at",
"created_at",
) )
list_filter = ("status", "performance", "created_at", "confirmed_at") search_fields = (
search_fields = ("name", "email", "phone", "performance__show__title") "name",
"email",
"phone",
"performance__show__title",
"performance__venue__name",
)
inlines = (ReservationTokenInline,) inlines = (ReservationTokenInline,)
list_select_related = ("performance", "performance__show", "performance__venue") list_select_related = ("performance", "performance__show", "performance__venue")
readonly_fields = ("created_at", "updated_at", "confirmed_at", "qr_code_generated_at") readonly_fields = (
"status",
"show_title",
"performance_starts_at",
"venue_name",
"confirmation_state_display",
"check_in_state_display",
"created_at",
"updated_at",
"confirmed_at",
"qr_code_generated_at",
)
autocomplete_fields = ("performance",) autocomplete_fields = ("performance",)
def get_queryset(self, request):
return super().get_queryset(request).select_related(
"performance",
"performance__show",
"performance__venue",
"check_in",
)
def get_inlines(self, request, obj):
if obj is None:
return ()
return super().get_inlines(request, obj)
def get_changeform_initial_data(self, request):
initial = super().get_changeform_initial_data(request)
performance_id = request.GET.get("performance")
if performance_id:
initial["performance"] = performance_id
return initial
def get_fieldsets(self, request, obj=None):
if obj is None:
return (
(
"Create manual reservation",
{
"description": (
"Use this form when staff needs to enter a reservation manually. "
"The reservation stays pending and the standard confirmation email is sent automatically."
),
"fields": ("performance", "name", "email", "phone", "party_size", "notes"),
},
),
)
return (
(
"Reservation",
{
"fields": (
"performance",
"show_title",
"performance_starts_at",
"venue_name",
"name",
"email",
"phone",
"party_size",
"notes",
),
},
),
(
"Operational status",
{
"fields": (
"status",
"confirmation_state_display",
"check_in_state_display",
"confirmed_at",
"qr_code_generated_at",
),
},
),
(
"Timestamps",
{
"classes": ("collapse",),
"fields": ("created_at", "updated_at"),
},
),
)
def save_model(self, request, obj, form, change):
if change:
super().save_model(request, obj, form, change)
return
try:
result = create_pending_reservation(
performance_id=form.cleaned_data["performance"].id,
name=form.cleaned_data["name"],
email=form.cleaned_data["email"],
phone=form.cleaned_data.get("phone", ""),
party_size=form.cleaned_data["party_size"],
notes=form.cleaned_data.get("notes", ""),
)
except PerformanceNotAvailable as exc:
form.add_error("performance", str(exc))
raise forms.ValidationError(str(exc)) from exc
created = result.reservation
obj.pk = created.pk
obj._state.adding = False
obj.performance = created.performance
obj.status = created.status
obj.name = created.name
obj.email = created.email
obj.phone = created.phone
obj.party_size = created.party_size
obj.notes = created.notes
obj.created_at = created.created_at
obj.updated_at = created.updated_at
obj.confirmed_at = created.confirmed_at
obj.qr_code_generated_at = created.qr_code_generated_at
self.message_user(
request,
"Pending reservation created. The guest must confirm it from the email link before check-in.",
level=messages.SUCCESS,
)
@admin.display(description="Show", ordering="performance__show__title")
def show_title(self, obj):
return obj.performance.show.title
@admin.display(description="Performance", ordering="performance__starts_at")
def performance_starts_at(self, obj):
return obj.performance.starts_at
@admin.display(description="Venue", ordering="performance__venue__name")
def venue_name(self, obj):
return obj.performance.venue.name
@admin.display(description="Confirmation")
def confirmation_state_display(self, obj):
if obj.status == Reservation.Status.CONFIRMED:
return "Confirmed"
if obj.status == Reservation.Status.EXPIRED:
return "Confirmation expired"
if obj.status == Reservation.Status.CANCELLED:
return "Cancelled"
return "Waiting for email confirmation"
@admin.display(description="Check-in")
def check_in_state_display(self, obj):
return "Checked in" if hasattr(obj, "check_in") else "Not checked in"
@admin.register(ReservationToken) @admin.register(ReservationToken)
class ReservationTokenAdmin(admin.ModelAdmin): class ReservationTokenAdmin(admin.ModelAdmin):

View File

@@ -0,0 +1,84 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import TestCase
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import timezone
from bookings.models import Reservation, ReservationToken
from shows.models import Performance, Show, Venue
@override_settings(
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
SITE_BASE_URL="https://tickets.azionelab.example",
)
class ReservationAdminTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.admin_user = user_model.objects.create_superuser(
username="admin-bookings",
email="admin@example.com",
password="password123",
)
self.client.force_login(self.admin_user)
self.show = Show.objects.create(
title="Open Stage",
slug="open-stage-admin",
is_published=True,
)
self.venue = Venue.objects.create(
name="AzioneLab Theatre",
slug="azionelab-theatre-admin",
address="Via Example 1",
city="Rome",
)
self.performance = Performance.objects.create(
show=self.show,
venue=self.venue,
starts_at=timezone.now() + timedelta(days=7),
room_capacity=20,
)
def test_reservation_add_page_accepts_preselected_performance(self):
response = self.client.get(
reverse("admin:bookings_reservation_add"),
{"performance": self.performance.id},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Create manual reservation")
self.assertContains(response, "The reservation stays pending")
def test_admin_can_create_manual_reservation_with_standard_email_flow(self):
response = self.client.post(
reverse("admin:bookings_reservation_add"),
{
"performance": self.performance.id,
"name": "Maria Rossi",
"email": "maria@example.com",
"phone": "+390600000000",
"party_size": 2,
"notes": "Entered by staff at the venue desk.",
"_save": "Save",
},
)
reservation = Reservation.objects.get()
self.assertEqual(response.status_code, 302)
self.assertEqual(reservation.performance, self.performance)
self.assertEqual(reservation.status, Reservation.Status.PENDING)
self.assertEqual(reservation.party_size, 2)
self.assertTrue(
ReservationToken.objects.filter(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
).exists()
)
self.assertEqual(len(mail.outbox), 1)
self.assertIn(
"https://tickets.azionelab.example/api/reservations/confirm/?token=",
mail.outbox[0].body,
)

View File

@@ -18,6 +18,7 @@ class CheckInPreviewResponseSerializer(serializers.Serializer):
reservation_id = serializers.IntegerField() reservation_id = serializers.IntegerField()
performance_id = serializers.IntegerField() performance_id = serializers.IntegerField()
show_title = serializers.CharField() show_title = serializers.CharField()
venue_name = serializers.CharField()
starts_at = serializers.DateTimeField() starts_at = serializers.DateTimeField()
party_size = serializers.IntegerField() party_size = serializers.IntegerField()

View File

@@ -57,6 +57,7 @@ class CheckInApiTests(APITestCase):
self.assertEqual(response.data["reservation_id"], reservation.id) self.assertEqual(response.data["reservation_id"], reservation.id)
self.assertEqual(response.data["performance_id"], self.performance.id) self.assertEqual(response.data["performance_id"], self.performance.id)
self.assertEqual(response.data["show_title"], self.show.title) self.assertEqual(response.data["show_title"], self.show.title)
self.assertEqual(response.data["venue_name"], self.venue.name)
self.assertEqual(response.data["party_size"], reservation.party_size) self.assertEqual(response.data["party_size"], reservation.party_size)
self.assertNotIn("name", response.data) self.assertNotIn("name", response.data)
self.assertNotIn("email", response.data) self.assertNotIn("email", response.data)

View File

@@ -65,6 +65,7 @@ def check_in_preview(request):
"reservation_id": preview.reservation_id, "reservation_id": preview.reservation_id,
"performance_id": preview.performance_id, "performance_id": preview.performance_id,
"show_title": preview.show_title, "show_title": preview.show_title,
"venue_name": preview.venue_name,
"starts_at": preview.starts_at, "starts_at": preview.starts_at,
"party_size": preview.party_size, "party_size": preview.party_size,
} }

View File

@@ -1,4 +1,6 @@
from django.contrib import admin from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from .models import Performance, Show, Venue from .models import Performance, Show, Venue
@@ -32,6 +34,7 @@ class PerformanceAdmin(admin.ModelAdmin):
"manually_occupied_seats", "manually_occupied_seats",
"available_seats_display", "available_seats_display",
"is_booking_enabled", "is_booking_enabled",
"create_reservation_link",
) )
list_filter = ("is_booking_enabled", "starts_at", "show", "venue") list_filter = ("is_booking_enabled", "starts_at", "show", "venue")
search_fields = ("show__title", "venue__name", "venue__city") search_fields = ("show__title", "venue__name", "venue__city")
@@ -49,3 +52,15 @@ class PerformanceAdmin(admin.ModelAdmin):
): ):
return "-" return "-"
return obj.available_seats() return obj.available_seats()
@admin.display(description="Manual reservation")
def create_reservation_link(self, obj):
if not getattr(obj, "pk", None):
return "-"
url = reverse("admin:bookings_reservation_add")
return format_html(
'<a href="{}?performance={}">Create reservation</a>',
url,
obj.pk,
)

View File

@@ -271,6 +271,7 @@ Response `200 OK`:
"reservation_id": 123, "reservation_id": 123,
"performance_id": 10, "performance_id": 10,
"show_title": "The Open Stage", "show_title": "The Open Stage",
"venue_name": "AzioneLab Theatre",
"starts_at": "2026-05-15T20:30:00+02:00", "starts_at": "2026-05-15T20:30:00+02:00",
"party_size": 2 "party_size": 2
} }

View File

@@ -101,6 +101,19 @@ The QR code must not contain:
The token remains opaque throughout the flow. The QR code must not expose visitor name, email address, phone number, notes, or other personal data. The token remains opaque throughout the flow. The QR code must not expose visitor name, email address, phone number, notes, or other personal data.
## Manual Reservations In Admin
Staff can also create a reservation manually from Django admin for a specific performance.
This operational flow should still follow the same backend rules as the public booking flow:
1. staff selects the performance and enters guest contact details and party size;
2. the backend validates booking availability and capacity;
3. the backend creates a `pending` reservation;
4. the backend creates the normal confirmation token;
5. the backend sends the standard confirmation email;
6. the guest still confirms through the email link before the reservation becomes confirmed and usable for check-in.
## Duplicate Check-In ## Duplicate Check-In
If the same QR code is scanned again: If the same QR code is scanned again:

View File

@@ -1,6 +1,14 @@
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; import {
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
ViewChild,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
@@ -10,7 +18,11 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { CheckInPreviewResponse, ShowsApiService } from '../services/shows-api.service'; import {
CheckInConfirmResponse,
CheckInPreviewResponse,
ShowsApiService,
} from '../services/shows-api.service';
type UiState = type UiState =
| 'idle' | 'idle'
@@ -24,6 +36,18 @@ type UiState =
| 'unauthorized' | 'unauthorized'
| 'error'; | 'error';
type CameraState = 'ready' | 'starting' | 'active' | 'unsupported' | 'denied' | 'error';
type DetectedBarcode = {
rawValue?: string;
};
type BarcodeDetectorInstance = {
detect(source: HTMLCanvasElement): Promise<DetectedBarcode[]>;
};
type BarcodeDetectorConstructor = new (options?: { formats?: string[] }) => BarcodeDetectorInstance;
@Component({ @Component({
standalone: true, standalone: true,
imports: [ imports: [
@@ -41,11 +65,49 @@ type UiState =
<header class="page-header"> <header class="page-header">
<p class="eyebrow">Staff check-in</p> <p class="eyebrow">Staff check-in</p>
<h1>Token validation</h1> <h1>Token validation</h1>
<p class="supporting">Enter an opaque token to preview admission data and confirm entrance.</p> <p class="supporting">Enter a token manually or scan a QR code to preview admission data and confirm entrance.</p>
</header> </header>
<mat-card class="content-card"> <mat-card class="content-card">
<mat-card-content> <mat-card-content>
<section class="scanner-panel">
<div class="scanner-copy">
<h2>Camera scan</h2>
<p>Optional on supported browsers. If the QR contains a full check-in URL, the token is extracted automatically.</p>
</div>
<div class="actions scanner-actions">
@if (cameraState() === 'active') {
<button mat-stroked-button type="button" (click)="stopScanner()">Stop camera</button>
} @else {
<button
mat-stroked-button
type="button"
(click)="startScanner()"
[disabled]="cameraState() === 'unsupported' || cameraState() === 'starting'"
>
@if (cameraState() === 'starting') {
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
<span>Starting camera...</span>
} @else {
<span>Use camera</span>
}
</button>
}
</div>
@if (cameraState() === 'active') {
<div class="camera-frame">
<video #scannerVideo autoplay playsinline muted></video>
<canvas #scannerCanvas class="scanner-canvas" aria-hidden="true"></canvas>
</div>
}
@if (cameraMessage()) {
<p class="camera-message">{{ cameraMessage() }}</p>
}
</section>
<form [formGroup]="tokenForm" (ngSubmit)="preview()" novalidate> <form [formGroup]="tokenForm" (ngSubmit)="preview()" novalidate>
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Opaque token</mat-label> <mat-label>Opaque token</mat-label>
@@ -69,19 +131,22 @@ type UiState =
</div> </div>
</form> </form>
@if (state() === 'preview_success' && previewData()) { @if (previewData() && shouldShowPreview()) {
<section class="preview-panel" aria-live="polite"> <section class="preview-panel" aria-live="polite">
<h2>Admission preview</h2> <h2>Admission preview</h2>
<dl> <dl>
<div><dt>Show</dt><dd>{{ previewData()!.show_title }}</dd></div> <div><dt>Show</dt><dd>{{ previewData()!.show_title }}</dd></div>
<div><dt>Venue</dt><dd>{{ previewData()!.venue_name }}</dd></div>
<div><dt>Starts at</dt><dd>{{ previewData()!.starts_at | date: 'EEEE d MMMM, HH:mm' }}</dd></div> <div><dt>Starts at</dt><dd>{{ previewData()!.starts_at | date: 'EEEE d MMMM, HH:mm' }}</dd></div>
<div><dt>Party size</dt><dd>{{ previewData()!.party_size }}</dd></div> <div><dt>Party size</dt><dd>{{ previewData()!.party_size }}</dd></div>
<div><dt>Reservation</dt><dd>#{{ previewData()!.reservation_id }}</dd></div> <div><dt>Reservation</dt><dd>#{{ previewData()!.reservation_id }}</dd></div>
</dl> </dl>
<button mat-flat-button type="button" (click)="confirm()" [disabled]="isBusy()"> <button mat-flat-button type="button" (click)="confirm()" [disabled]="isBusy() || state() === 'confirm_success'">
@if (state() === 'confirm_loading') { @if (state() === 'confirm_loading') {
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner> <mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
<span>Confirming...</span> <span>Confirming...</span>
} @else if (state() === 'confirm_success') {
<span>Checked in</span>
} @else { } @else {
<span>Confirm check-in</span> <span>Confirm check-in</span>
} }
@@ -89,8 +154,10 @@ type UiState =
</section> </section>
} }
@if (state() === 'confirm_success') { @if (state() === 'confirm_success' && confirmData()) {
<p class="success-message" aria-live="polite">Check-in confirmed successfully.</p> <p class="success-message" aria-live="polite">
Check-in confirmed at {{ confirmData()!.checked_in_at | date: 'HH:mm' }}.
</p>
} }
@if (state() === 'invalid_token') { @if (state() === 'invalid_token') {
@@ -148,6 +215,48 @@ type UiState =
box-shadow: var(--azionelab-shadow); box-shadow: var(--azionelab-shadow);
} }
.scanner-panel {
display: grid;
gap: 14px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--azionelab-border);
}
.scanner-copy h2 {
margin: 0 0 6px;
font-size: 1.15rem;
}
.scanner-copy p,
.camera-message {
margin: 0;
color: var(--azionelab-muted);
line-height: 1.5;
}
.camera-message {
font-size: 0.95rem;
}
.camera-frame {
overflow: hidden;
border-radius: 8px;
border: 1px solid var(--azionelab-border);
background: #151515;
}
.camera-frame video {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.scanner-canvas {
display: none;
}
.full-width { .full-width {
width: 100%; width: 100%;
} }
@@ -159,7 +268,12 @@ type UiState =
flex-wrap: wrap; flex-wrap: wrap;
} }
button[mat-flat-button] { .scanner-actions {
justify-content: flex-start;
}
button[mat-flat-button],
button[mat-stroked-button] {
min-width: 150px; min-width: 150px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -213,6 +327,20 @@ export class CheckInPlaceholderPageComponent {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly showsApi = inject(ShowsApiService); private readonly showsApi = inject(ShowsApiService);
private readonly barcodeDetectorCtor = (globalThis as { BarcodeDetector?: BarcodeDetectorConstructor }).BarcodeDetector;
private readonly scannerSupported =
!!this.barcodeDetectorCtor &&
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === 'function';
private detector: BarcodeDetectorInstance | null = null;
private scannerStream: MediaStream | null = null;
private scanFrameId: number | null = null;
private scanInFlight = false;
@ViewChild('scannerVideo') private scannerVideo?: ElementRef<HTMLVideoElement>;
@ViewChild('scannerCanvas') private scannerCanvas?: ElementRef<HTMLCanvasElement>;
protected readonly tokenForm = this.formBuilder.nonNullable.group({ protected readonly tokenForm = this.formBuilder.nonNullable.group({
token: ['', [Validators.required]], token: ['', [Validators.required]],
@@ -220,6 +348,17 @@ export class CheckInPlaceholderPageComponent {
protected readonly state = signal<UiState>('idle'); protected readonly state = signal<UiState>('idle');
protected readonly previewData = signal<CheckInPreviewResponse | null>(null); protected readonly previewData = signal<CheckInPreviewResponse | null>(null);
protected readonly confirmData = signal<CheckInConfirmResponse | null>(null);
protected readonly cameraState = signal<CameraState>(this.scannerSupported ? 'ready' : 'unsupported');
protected readonly cameraMessage = signal(
this.scannerSupported
? 'Open the camera to scan a QR code, or keep using manual token entry.'
: 'Camera scanning is not available in this browser. Manual token entry still works.',
);
constructor() {
this.destroyRef.onDestroy(() => this.stopScanner());
}
protected preview(): void { protected preview(): void {
if (this.tokenForm.invalid) { if (this.tokenForm.invalid) {
@@ -235,6 +374,7 @@ export class CheckInPlaceholderPageComponent {
this.state.set('preview_loading'); this.state.set('preview_loading');
this.previewData.set(null); this.previewData.set(null);
this.confirmData.set(null);
this.showsApi.previewCheckIn(token) this.showsApi.previewCheckIn(token)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@@ -259,17 +399,174 @@ export class CheckInPlaceholderPageComponent {
this.showsApi.confirmCheckIn(token) this.showsApi.confirmCheckIn(token)
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { next: (response) => {
this.confirmData.set(response);
this.state.set('confirm_success'); this.state.set('confirm_success');
}, },
error: (error: HttpErrorResponse) => this.setErrorState(error), error: (error: HttpErrorResponse) => this.setErrorState(error),
}); });
} }
protected async startScanner(): Promise<void> {
if (!this.scannerSupported || !this.barcodeDetectorCtor) {
this.cameraState.set('unsupported');
this.cameraMessage.set('Camera scanning is not available in this browser. Manual token entry still works.');
return;
}
this.stopScanner();
this.cameraState.set('starting');
this.cameraMessage.set('Starting camera...');
try {
this.scannerStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' } },
audio: false,
});
this.detector = new this.barcodeDetectorCtor({ formats: ['qr_code'] });
this.cameraState.set('active');
this.cameraMessage.set('Point the camera at the visitor QR code.');
this.scheduleScan();
} catch (error) {
this.stopScanner();
if (error instanceof DOMException && (error.name === 'NotAllowedError' || error.name === 'SecurityError')) {
this.cameraState.set('denied');
this.cameraMessage.set('Camera access was denied. You can continue with manual token entry.');
return;
}
this.cameraState.set('error');
this.cameraMessage.set('Could not start the camera. You can continue with manual token entry.');
}
}
protected stopScanner(): void {
if (this.scanFrameId !== null) {
cancelAnimationFrame(this.scanFrameId);
this.scanFrameId = null;
}
if (this.scannerStream) {
for (const track of this.scannerStream.getTracks()) {
track.stop();
}
this.scannerStream = null;
}
if (this.scannerVideo?.nativeElement) {
this.scannerVideo.nativeElement.pause();
this.scannerVideo.nativeElement.srcObject = null;
}
this.detector = null;
this.scanInFlight = false;
if (this.scannerSupported && this.cameraState() === 'active') {
this.cameraState.set('ready');
this.cameraMessage.set('Camera stopped. You can scan again or continue with manual token entry.');
}
}
protected isBusy(): boolean { protected isBusy(): boolean {
return this.state() === 'preview_loading' || this.state() === 'confirm_loading'; return this.state() === 'preview_loading' || this.state() === 'confirm_loading';
} }
protected shouldShowPreview(): boolean {
return (
this.state() === 'preview_success'
|| this.state() === 'confirm_loading'
|| this.state() === 'confirm_success'
);
}
private scheduleScan(): void {
this.scanFrameId = requestAnimationFrame(() => {
void this.scanFrame();
});
}
private async scanFrame(): Promise<void> {
if (this.cameraState() !== 'active' || !this.detector) {
return;
}
const video = this.scannerVideo?.nativeElement;
const canvas = this.scannerCanvas?.nativeElement;
if (!video || !canvas || this.scanInFlight) {
this.scheduleScan();
return;
}
if (!this.scannerStream && !video.srcObject) {
this.scheduleScan();
return;
}
if (video.srcObject !== this.scannerStream) {
video.srcObject = this.scannerStream;
await video.play();
}
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || video.videoWidth === 0) {
this.scheduleScan();
return;
}
const context = canvas.getContext('2d');
if (!context) {
this.cameraState.set('error');
this.cameraMessage.set('Camera scan is not available right now. Please enter the token manually.');
this.stopScanner();
return;
}
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
this.scanInFlight = true;
try {
const barcodes = await this.detector.detect(canvas);
const rawValue = barcodes[0]?.rawValue ?? '';
const token = this.extractToken(rawValue);
if (token) {
this.tokenForm.controls.token.setValue(token);
this.tokenForm.controls.token.markAsTouched();
this.cameraMessage.set('QR captured. Validating token...');
this.stopScanner();
this.preview();
return;
}
} catch {
this.cameraState.set('error');
this.cameraMessage.set('Camera scan failed. Please enter the token manually.');
this.stopScanner();
return;
} finally {
this.scanInFlight = false;
}
this.scheduleScan();
}
private extractToken(rawValue: string): string {
const trimmedValue = rawValue.trim();
if (!trimmedValue) {
return '';
}
try {
const parsedUrl = new URL(trimmedValue);
return parsedUrl.searchParams.get('token')?.trim() ?? trimmedValue;
} catch {
return trimmedValue;
}
}
private setErrorState(error: HttpErrorResponse): void { private setErrorState(error: HttpErrorResponse): void {
if (error.status === 401 || error.status === 403) { if (error.status === 401 || error.status === 403) {
this.state.set('unauthorized'); this.state.set('unauthorized');

View File

@@ -57,6 +57,7 @@ export type CheckInPreviewResponse = {
reservation_id: number; reservation_id: number;
performance_id: number; performance_id: number;
show_title: string; show_title: string;
venue_name: string;
starts_at: string; starts_at: string;
party_size: number; party_size: number;
}; };