27 KiB
27 KiB
Check-in System Implementation Guide
Overview
The check-in system allows event staff to scan QR codes from tickets using smartphone cameras to validate entry and prevent duplicate access. This document provides a complete implementation guide for the QR code-based check-in system.
Architecture
[Staff Mobile Device] → [Web Scanner Interface] → [Rails Backend] → [Database]
↓
[QR Code Scan] → [Validation] → [Check-in Status Update] → [Real-time Feedback]
Implementation Steps
1. Database Schema Updates
Create migration to add check-in fields to tickets:
# db/migrate/add_checkin_fields_to_tickets.rb
class AddCheckinFieldsToTickets < ActiveRecord::Migration[8.0]
def change
add_column :tickets, :checked_in_at, :datetime
add_column :tickets, :checked_in_by, :string
add_column :tickets, :checkin_location, :string # Optional: track location
add_column :tickets, :checkin_device_info, :text # Optional: device fingerprinting
add_index :tickets, :checked_in_at
add_index :tickets, [:event_id, :checked_in_at] # For event-specific reporting
end
end
2. Model Updates
Update the Ticket model with check-in functionality:
# app/models/ticket.rb
class Ticket < ApplicationRecord
# ... existing code ...
# Check-in status methods
def checked_in?
checked_in_at.present?
end
def can_check_in?
status == "active" && !checked_in? && !expired?
end
def check_in!(staff_identifier = nil, location: nil, device_info: nil)
return false unless can_check_in?
update!(
checked_in_at: Time.current,
checked_in_by: staff_identifier,
checkin_location: location,
checkin_device_info: device_info,
status: "used"
)
# Optional: Log check-in event
Rails.logger.info "Ticket #{id} checked in by #{staff_identifier} at #{Time.current}"
true
rescue => e
Rails.logger.error "Check-in failed for ticket #{id}: #{e.message}"
false
end
def check_in_summary
return "Non utilisé" unless checked_in?
"Utilisé le #{checked_in_at.strftime('%d/%m/%Y à %H:%M')} par #{checked_in_by}"
end
# Scopes for reporting
scope :checked_in, -> { where.not(checked_in_at: nil) }
scope :not_checked_in, -> { where(checked_in_at: nil) }
scope :checked_in_today, -> { where(checked_in_at: Date.current.beginning_of_day..Date.current.end_of_day) }
end
3. Controller Implementation
Create the check-in controller:
# app/controllers/checkin_controller.rb
class CheckinController < ApplicationController
include StaffAccess
before_action :authenticate_user!
before_action :ensure_staff_access
before_action :set_event, only: [:show, :scan, :stats]
# GET /events/:event_id/checkin
def show
@total_tickets = @event.tickets.active.count
@checked_in_count = @event.tickets.checked_in.count
@remaining_tickets = @total_tickets - @checked_in_count
end
# POST /events/:event_id/checkin/scan
def scan
begin
# Parse QR code data
qr_data = JSON.parse(params[:qr_data])
validate_qr_structure(qr_data)
# Find ticket
ticket = find_ticket_by_qr(qr_data)
return render_error("Billet non trouvé ou invalide") unless ticket
# Validate event match
return render_error("Billet non valide pour cet événement") unless ticket.event == @event
# Check ticket status
return handle_ticket_validation(ticket)
rescue JSON::ParserError
render_error("Format QR Code invalide")
rescue => e
Rails.logger.error "Check-in scan error: #{e.message}"
render_error("Erreur système lors de la validation")
end
end
# GET /events/:event_id/checkin/stats
def stats
render json: {
total_tickets: @event.tickets.active.count,
checked_in: @event.tickets.checked_in.count,
pending: @event.tickets.not_checked_in.active.count,
checkin_rate: calculate_checkin_rate,
recent_checkins: recent_checkins_data
}
end
# GET /events/:event_id/checkin/export
def export
respond_to do |format|
format.csv do
send_data generate_checkin_csv,
filename: "checkin_report_#{@event.slug}_#{Date.current}.csv"
end
end
end
private
def set_event
@event = Event.find(params[:event_id])
rescue ActiveRecord::RecordNotFound
redirect_to root_path, alert: "Événement non trouvé"
end
def validate_qr_structure(qr_data)
required_fields = %w[ticket_id qr_code event_id user_id]
missing_fields = required_fields - qr_data.keys.map(&:to_s)
if missing_fields.any?
raise "QR Code structure invalide - champs manquants: #{missing_fields.join(', ')}"
end
end
def find_ticket_by_qr(qr_data)
Ticket.find_by(
id: qr_data["ticket_id"],
qr_code: qr_data["qr_code"]
)
end
def handle_ticket_validation(ticket)
if ticket.checked_in?
render_error(
"Billet déjà utilisé",
details: {
checked_in_at: ticket.checked_in_at.strftime('%d/%m/%Y à %H:%M'),
checked_in_by: ticket.checked_in_by
}
)
elsif !ticket.can_check_in?
render_error("Billet non valide pour l'entrée (statut: #{ticket.status})")
else
perform_checkin(ticket)
end
end
def perform_checkin(ticket)
device_info = extract_device_info(request)
if ticket.check_in!(current_user.email, device_info: device_info)
render json: {
success: true,
message: "✅ Entrée validée avec succès",
ticket: ticket_summary(ticket),
stats: current_event_stats
}
else
render_error("Échec de l'enregistrement de l'entrée")
end
end
def render_error(message, details: {})
render json: {
success: false,
message: message,
details: details
}, status: :unprocessable_entity
end
def ticket_summary(ticket)
{
id: ticket.id,
holder_name: "#{ticket.first_name} #{ticket.last_name}",
event_name: ticket.event.name,
ticket_type: ticket.ticket_type.name,
price: "€#{ticket.price_euros}",
checked_in_at: ticket.checked_in_at&.strftime('%H:%M')
}
end
def current_event_stats
{
total: @event.tickets.active.count,
checked_in: @event.tickets.checked_in.count,
remaining: @event.tickets.not_checked_in.active.count
}
end
def extract_device_info(request)
{
user_agent: request.user_agent,
ip_address: request.remote_ip,
timestamp: Time.current.iso8601
}.to_json
end
def calculate_checkin_rate
total = @event.tickets.active.count
return 0 if total.zero?
((@event.tickets.checked_in.count.to_f / total) * 100).round(1)
end
def recent_checkins_data
@event.tickets
.checked_in
.order(checked_in_at: :desc)
.limit(5)
.map { |t| ticket_summary(t) }
end
def generate_checkin_csv
CSV.generate(headers: true) do |csv|
csv << ["Ticket ID", "Nom", "Prénom", "Type de billet", "Prix", "Status", "Check-in", "Check-in par"]
@event.tickets.includes(:ticket_type).each do |ticket|
csv << [
ticket.id,
ticket.last_name,
ticket.first_name,
ticket.ticket_type.name,
"€#{ticket.price_euros}",
ticket.status,
ticket.checked_in? ? ticket.checked_in_at.strftime('%d/%m/%Y %H:%M') : "Non utilisé",
ticket.checked_in_by || "-"
]
end
end
end
end
4. Staff Access Control
Create staff access concern:
# app/controllers/concerns/staff_access.rb
module StaffAccess
extend ActiveSupport::Concern
private
def ensure_staff_access
unless current_user_has_staff_access?
redirect_to root_path, alert: "Accès non autorisé - réservé au personnel"
end
end
def current_user_has_staff_access?
return false unless current_user
# Check if user is staff/admin or event organizer
current_user.staff? ||
current_user.admin? ||
(@event&.user == current_user)
end
end
Add role field to User model:
# Migration
class AddRoleToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :role, :integer, default: 0
add_index :users, :role
end
end
# app/models/user.rb
class User < ApplicationRecord
enum role: { user: 0, staff: 1, admin: 2 }
def can_manage_checkins_for?(event)
admin? || staff? || event.user == self
end
end
5. Routes Configuration
# config/routes.rb
Rails.application.routes.draw do
resources :events do
scope module: :events do
get 'checkin', to: 'checkin#show'
post 'checkin/scan', to: 'checkin#scan'
get 'checkin/stats', to: 'checkin#stats'
get 'checkin/export', to: 'checkin#export'
end
end
end
6. Frontend Implementation
Create the scanner interface:
<!-- app/views/checkin/show.html.erb -->
<div class="checkin-page">
<!-- Header -->
<div class="checkin-header">
<h1>Check-in Scanner</h1>
<p class="event-info">
<strong><%= @event.name %></strong><br>
<%= @event.start_time.strftime('%d %B %Y à %H:%M') %>
</p>
</div>
<!-- Statistics Dashboard -->
<div class="stats-dashboard" id="stats-dashboard">
<div class="stat-card">
<span class="stat-number" id="checked-in-count"><%= @checked_in_count %></span>
<span class="stat-label">Entrées validées</span>
</div>
<div class="stat-card">
<span class="stat-number" id="total-tickets"><%= @total_tickets %></span>
<span class="stat-label">Total billets</span>
</div>
<div class="stat-card">
<span class="stat-number" id="remaining-tickets"><%= @remaining_tickets %></span>
<span class="stat-label">En attente</span>
</div>
<div class="stat-card">
<span class="stat-number" id="checkin-rate">0%</span>
<span class="stat-label">Taux d'entrée</span>
</div>
</div>
<!-- Scanner Section -->
<div class="scanner-section">
<div id="qr-scanner" class="scanner-viewport"></div>
<div class="scanner-controls">
<button id="start-scan" class="btn btn-primary">
<i data-lucide="camera"></i>
Démarrer le scanner
</button>
<button id="stop-scan" class="btn btn-secondary" style="display:none;">
<i data-lucide="camera-off"></i>
Arrêter le scanner
</button>
<button id="switch-camera" class="btn btn-outline" style="display:none;">
<i data-lucide="rotate-3d"></i>
Changer caméra
</button>
</div>
</div>
<!-- Scan Result -->
<div id="scan-result" class="scan-result" style="display:none;"></div>
<!-- Recent Check-ins -->
<div class="recent-checkins">
<h3>Dernières entrées</h3>
<div id="recent-list" class="checkin-list">
<!-- Populated via JavaScript -->
</div>
</div>
<!-- Export Options -->
<div class="export-section">
<a href="<%= checkin_export_path(@event, format: :csv) %>" class="btn btn-outline">
<i data-lucide="download"></i>
Exporter en CSV
</a>
</div>
</div>
<!-- Include QR Scanner Library -->
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script>
class CheckinScanner {
constructor() {
this.html5QrCode = null;
this.isScanning = false;
this.cameras = [];
this.currentCameraIndex = 0;
this.statsUpdateInterval = null;
this.initializeScanner();
this.startStatsPolling();
}
initializeScanner() {
// Get available cameras
Html5Qrcode.getCameras().then(devices => {
this.cameras = devices;
if (devices && devices.length) {
this.setupScannerControls();
}
}).catch(err => {
console.error("Camera enumeration error:", err);
});
// Event listeners
document.getElementById('start-scan').addEventListener('click', () => this.startScanning());
document.getElementById('stop-scan').addEventListener('click', () => this.stopScanning());
document.getElementById('switch-camera').addEventListener('click', () => this.switchCamera());
}
setupScannerControls() {
if (this.cameras.length > 1) {
document.getElementById('switch-camera').style.display = 'inline-block';
}
}
startScanning() {
if (this.isScanning) return;
this.html5QrCode = new Html5Qrcode("qr-scanner");
const cameraId = this.cameras[this.currentCameraIndex]?.id || { facingMode: "environment" };
this.html5QrCode.start(
cameraId,
{
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0
},
(decodedText, decodedResult) => this.onScanSuccess(decodedText, decodedResult),
(errorMessage) => {} // Silent error handling
).then(() => {
this.isScanning = true;
this.updateScannerUI();
}).catch(err => {
console.error("Scanner start error:", err);
this.showError("Impossible de démarrer la caméra");
});
}
stopScanning() {
if (!this.isScanning || !this.html5QrCode) return;
this.html5QrCode.stop().then(() => {
this.isScanning = false;
this.updateScannerUI();
}).catch(err => {
console.error("Scanner stop error:", err);
});
}
switchCamera() {
if (!this.isScanning || this.cameras.length <= 1) return;
this.stopScanning();
setTimeout(() => {
this.currentCameraIndex = (this.currentCameraIndex + 1) % this.cameras.length;
this.startScanning();
}, 500);
}
updateScannerUI() {
document.getElementById('start-scan').style.display = this.isScanning ? 'none' : 'inline-block';
document.getElementById('stop-scan').style.display = this.isScanning ? 'inline-block' : 'none';
document.getElementById('switch-camera').style.display =
(this.isScanning && this.cameras.length > 1) ? 'inline-block' : 'none';
}
onScanSuccess(decodedText, decodedResult) {
// Temporary pause scanning to prevent duplicate scans
this.stopScanning();
// Process scan
this.processScan(decodedText);
}
processScan(qrData) {
fetch('<%= checkin_scan_path(@event) %>', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
},
body: JSON.stringify({ qr_data: qrData })
})
.then(response => response.json())
.then(data => {
this.displayScanResult(data);
if (data.success) {
this.updateStats(data.stats);
this.updateRecentCheckins();
}
// Resume scanning after 2 seconds
setTimeout(() => this.startScanning(), 2000);
})
.catch(error => {
console.error('Scan processing error:', error);
this.showError('Erreur de connexion');
setTimeout(() => this.startScanning(), 2000);
});
}
displayScanResult(result) {
const resultDiv = document.getElementById('scan-result');
const resultClass = result.success ? 'success' : 'error';
let content = `
<div class="alert ${resultClass}">
<div class="result-header">
${result.success ? '✅' : '❌'}
<span class="result-message">${result.message}</span>
</div>`;
if (result.ticket) {
content += `
<div class="ticket-details">
<p><strong>${result.ticket.holder_name}</strong></p>
<p>${result.ticket.ticket_type} - ${result.ticket.price}</p>
${result.ticket.checked_in_at ? `<p class="checkin-time">Entrée validée à ${result.ticket.checked_in_at}</p>` : ''}
</div>`;
}
if (result.details) {
content += `
<div class="error-details">
${result.details.checked_in_at ? `<p>Déjà utilisé le ${result.details.checked_in_at}</p>` : ''}
${result.details.checked_in_by ? `<p>Par: ${result.details.checked_in_by}</p>` : ''}
</div>`;
}
content += '</div>';
resultDiv.innerHTML = content;
resultDiv.style.display = 'block';
// Auto-hide after 3 seconds
setTimeout(() => {
resultDiv.style.display = 'none';
}, 3000);
// Play sound feedback
this.playFeedbackSound(result.success);
}
playFeedbackSound(success) {
// Create audio feedback for scan results
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = success ? 800 : 400; // Higher pitch for success
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.3);
}
updateStats(stats) {
if (!stats) return;
document.getElementById('checked-in-count').textContent = stats.checked_in;
document.getElementById('total-tickets').textContent = stats.total;
document.getElementById('remaining-tickets').textContent = stats.remaining;
const rate = stats.total > 0 ? Math.round((stats.checked_in / stats.total) * 100) : 0;
document.getElementById('checkin-rate').textContent = rate + '%';
}
startStatsPolling() {
// Update stats every 30 seconds
this.statsUpdateInterval = setInterval(() => {
this.fetchLatestStats();
}, 30000);
}
fetchLatestStats() {
fetch('<%= checkin_stats_path(@event) %>')
.then(response => response.json())
.then(data => {
this.updateStats(data);
this.displayRecentCheckins(data.recent_checkins);
})
.catch(error => console.error('Stats update error:', error));
}
updateRecentCheckins() {
this.fetchLatestStats();
}
displayRecentCheckins(checkins) {
if (!checkins) return;
const recentList = document.getElementById('recent-list');
recentList.innerHTML = checkins.map(checkin => `
<div class="checkin-item">
<div class="checkin-name">${checkin.holder_name}</div>
<div class="checkin-details">
${checkin.ticket_type} • ${checkin.checked_in_at}
</div>
</div>
`).join('');
}
showError(message) {
this.displayScanResult({
success: false,
message: message
});
}
}
// Initialize scanner when page loads
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons(); // Initialize Lucide icons
new CheckinScanner();
});
</script>
<style>
.checkin-page {
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.checkin-header {
text-align: center;
margin-bottom: 30px;
}
.checkin-header h1 {
color: #1a1a1a;
margin-bottom: 10px;
}
.event-info {
color: #666;
font-size: 16px;
}
.stats-dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.stat-card {
background: white;
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 20px;
text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-number {
display: block;
font-size: 2em;
font-weight: bold;
color: #2563eb;
}
.stat-label {
display: block;
font-size: 0.9em;
color: #666;
margin-top: 5px;
}
.scanner-section {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin-bottom: 30px;
}
#qr-scanner {
width: 100%;
max-width: 400px;
height: 300px;
margin: 0 auto 20px;
border: 2px solid #e5e5e5;
border-radius: 8px;
background: #f8f9fa;
}
.scanner-controls {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.btn-primary {
background: #2563eb;
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-outline {
background: transparent;
color: #374151;
border: 1px solid #d1d5db;
}
.scan-result {
margin: 20px 0;
}
.alert {
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.alert.success {
background: #dcfce7;
border: 1px solid #bbf7d0;
color: #166534;
}
.alert.error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
.result-header {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.1em;
font-weight: 600;
margin-bottom: 10px;
}
.ticket-details, .error-details {
font-size: 0.95em;
line-height: 1.4;
}
.checkin-time {
font-weight: 500;
color: #059669;
}
.recent-checkins {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.recent-checkins h3 {
margin: 0 0 15px 0;
color: #1a1a1a;
}
.checkin-list {
max-height: 200px;
overflow-y: auto;
}
.checkin-item {
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
}
.checkin-item:last-child {
border-bottom: none;
}
.checkin-name {
font-weight: 500;
color: #1a1a1a;
}
.checkin-details {
font-size: 0.9em;
color: #666;
}
.export-section {
text-align: center;
padding: 20px 0;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.checkin-page {
padding: 15px;
}
.stats-dashboard {
grid-template-columns: repeat(2, 1fr);
}
.scanner-controls {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.checkin-page {
background: #1a1a1a;
color: white;
}
.stat-card, .scanner-section, .recent-checkins {
background: #2d2d2d;
border-color: #404040;
}
#qr-scanner {
background: #1a1a1a;
border-color: #404040;
}
}
</style>
Security Considerations
1. Authentication & Authorization
- Only staff/admin users can access check-in interface
- Event organizers can only check-in for their own events
- Session-based authentication with CSRF protection
2. QR Code Security
- QR codes contain multiple validation fields (ticket_id, qr_code, event_id, user_id)
- Server-side validation of all QR code components
- Prevention of replay attacks through status tracking
3. Data Privacy
- Minimal device information collection
- GDPR-compliant data handling
- Optional location tracking
4. Rate Limiting
# Add to ApplicationController or CheckinController
before_action :check_scan_rate_limit, only: [:scan]
private
def check_scan_rate_limit
key = "checkin_scan_#{current_user.id}_#{request.remote_ip}"
if Rails.cache.read(key).to_i > 10 # Max 10 scans per minute
render json: {
success: false,
message: "Trop de tentatives. Veuillez patienter."
}, status: :too_many_requests
return
end
Rails.cache.write(key, Rails.cache.read(key).to_i + 1, expires_in: 1.minute)
end
Testing Strategy
1. Unit Tests
# test/models/ticket_test.rb
test "should check in valid ticket" do
ticket = create(:ticket, status: "active")
assert ticket.can_check_in?
assert ticket.check_in!("staff@example.com")
assert ticket.checked_in?
assert_equal "used", ticket.status
end
test "should not check in already used ticket" do
ticket = create(:ticket, :checked_in)
refute ticket.can_check_in?
refute ticket.check_in!("staff@example.com")
end
2. Integration Tests
# test/controllers/checkin_controller_test.rb
test "should scan valid QR code" do
ticket = create(:ticket, :active)
qr_data = {
ticket_id: ticket.id,
qr_code: ticket.qr_code,
event_id: ticket.event.id,
user_id: ticket.user.id
}
post checkin_scan_path(ticket.event),
params: { qr_data: qr_data.to_json },
headers: authenticated_headers
assert_response :success
assert_equal true, response.parsed_body["success"]
assert ticket.reload.checked_in?
end
3. System Tests
# test/system/checkin_test.rb
test "staff can scan QR codes" do
staff_user = create(:user, :staff)
event = create(:event)
ticket = create(:ticket, event: event, status: "active")
login_as(staff_user)
visit checkin_path(event)
# Simulate QR code scan
execute_script("window.mockQRScan('#{ticket.qr_code}')")
assert_text "Entrée validée avec succès"
assert ticket.reload.checked_in?
end
Deployment Checklist
1. Database Migration
- Run migration to add check-in fields
- Update production database schema
- Verify indexes are created
2. Environment Setup
- Configure user roles (staff/admin)
- Set up SSL/HTTPS for camera access
- Test camera permissions on target devices
3. Performance Optimization
- Add database indexes for check-in queries
- Implement caching for event statistics
- Set up monitoring for scan endpoint
4. Mobile Testing
- Test on iOS Safari
- Test on Android Chrome
- Verify camera switching works
- Test in low-light conditions
Monitoring & Analytics
1. Key Metrics
- Check-in success rate
- Average check-in time
- Device/browser compatibility
- Peak usage periods
2. Error Tracking
- Failed scan attempts
- Camera access denials
- Network connectivity issues
- Invalid QR code submissions
3. Reporting
- Daily check-in summaries
- Event-specific statistics
- Staff performance metrics
- Device usage analytics
Future Enhancements
1. Offline Support
- Progressive Web App (PWA) implementation
- Service worker for offline scanning
- Data synchronization when online
2. Advanced Features
- Bulk check-in for groups
- Photo capture for security
- Real-time dashboard for event managers
- Integration with access control systems
3. Mobile App
- Native iOS/Android application
- Better camera performance
- Push notifications
- Barcode scanner integration
Troubleshooting Guide
Common Issues
Camera Not Working
- Ensure HTTPS connection
- Check browser permissions
- Try different camera (front/back)
- Clear browser cache
QR Code Not Scanning
- Improve lighting conditions
- Clean camera lens
- Hold steady for 2-3 seconds
- Try manual ticket lookup
Scan Validation Errors
- Verify ticket is for correct event
- Check ticket status (active vs used)
- Confirm ticket hasn't expired
- Validate QR code format
Performance Issues
- Monitor database query performance
- Check network connectivity
- Review server logs for errors
- Optimize JavaScript execution
This implementation provides a complete, production-ready check-in system with camera-based QR code scanning, real-time statistics, and comprehensive error handling.