1092 lines
27 KiB
Markdown
1092 lines
27 KiB
Markdown
# 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:
|
|
|
|
```ruby
|
|
# 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:
|
|
|
|
```ruby
|
|
# 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:
|
|
|
|
```ruby
|
|
# 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:
|
|
|
|
```ruby
|
|
# 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:
|
|
|
|
```ruby
|
|
# 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
|
|
|
|
```ruby
|
|
# 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:
|
|
|
|
```erb
|
|
<!-- 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
|
|
```ruby
|
|
# 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
|
|
```ruby
|
|
# 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
|
|
```ruby
|
|
# 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
|
|
```ruby
|
|
# 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. |