feat: add advanced analytics and territory management system
- Add comprehensive analytics components with export functionality - Implement territory management with manager performance tracking - Add seatmap components for venue layout management - Create customer management features with modal interface - Add advanced hooks for dashboard flags and territory data - Implement seat selection and venue management utilities - Add type definitions for ticketing and seatmap systems 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
12
reactrebuild0825/src/lib/arrayMove.ts
Normal file
12
reactrebuild0825/src/lib/arrayMove.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Safe array move utility that works with TypeScript strict mode
|
||||
export function arrayMove<T>(list: T[], from: number, to: number): T[] {
|
||||
if (from === to) return list;
|
||||
if (from < 0 || to < 0 || from >= list.length || to >= list.length) return list;
|
||||
|
||||
const copy = [...list];
|
||||
const [moved] = copy.splice(from, 1); // moved: T | undefined
|
||||
if (!moved) return list; // satisfies noUncheckedIndexedAccess
|
||||
|
||||
copy.splice(to, 0, moved); // moved: T
|
||||
return copy;
|
||||
}
|
||||
13
reactrebuild0825/src/lib/firebase.ts
Normal file
13
reactrebuild0825/src/lib/firebase.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Firebase completely removed - using mock authentication only
|
||||
// This file is kept for compatibility but exports nothing
|
||||
|
||||
console.warn('Firebase authentication has been replaced with mock authentication system');
|
||||
|
||||
// Export empty objects to prevent import errors
|
||||
export const auth = {};
|
||||
export const db = {};
|
||||
|
||||
// Mock firebase functions that might be imported elsewhere
|
||||
export const signInWithEmailAndPassword = () => Promise.reject(new Error('Firebase disabled - use mock auth'));
|
||||
export const signOut = () => Promise.reject(new Error('Firebase disabled - use mock auth'));
|
||||
export const onAuthStateChanged = () => () => {};
|
||||
319
reactrebuild0825/src/lib/qr-generator.ts
Normal file
319
reactrebuild0825/src/lib/qr-generator.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* QR Code Generation Utilities for Black Canyon Tickets
|
||||
* Supports both simple ticket IDs and signed tokens
|
||||
*/
|
||||
|
||||
export interface QRGenerationOptions {
|
||||
format: 'simple' | 'signed';
|
||||
errorCorrection: 'L' | 'M' | 'Q' | 'H';
|
||||
moduleSize: number;
|
||||
quietZone: number;
|
||||
backgroundColor: string;
|
||||
foregroundColor: string;
|
||||
}
|
||||
|
||||
export interface SignedTokenData {
|
||||
ticketId: string;
|
||||
eventId: string;
|
||||
zone?: string;
|
||||
seat?: string;
|
||||
expiresInDays?: number; // Default: 30 days
|
||||
}
|
||||
|
||||
export interface QRGenerationResult {
|
||||
qrData: string;
|
||||
backupCode: string;
|
||||
metadata: {
|
||||
format: 'simple' | 'signed';
|
||||
ticketId: string;
|
||||
eventId?: string;
|
||||
generatedAt: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Recommended settings for different use cases
|
||||
export const QR_PRESETS: Record<string, QRGenerationOptions> = {
|
||||
ticket: {
|
||||
format: 'signed',
|
||||
errorCorrection: 'M', // 15% error correction - good balance
|
||||
moduleSize: 3,
|
||||
quietZone: 4,
|
||||
backgroundColor: '#FFFFFF',
|
||||
foregroundColor: '#000000'
|
||||
},
|
||||
thermal_printer: {
|
||||
format: 'signed',
|
||||
errorCorrection: 'H', // 30% error correction for thermal printing
|
||||
moduleSize: 2,
|
||||
quietZone: 2,
|
||||
backgroundColor: '#FFFFFF',
|
||||
foregroundColor: '#000000'
|
||||
},
|
||||
email: {
|
||||
format: 'signed',
|
||||
errorCorrection: 'M',
|
||||
moduleSize: 4,
|
||||
quietZone: 4,
|
||||
backgroundColor: 'transparent',
|
||||
foregroundColor: '#000000'
|
||||
},
|
||||
mobile_wallet: {
|
||||
format: 'signed',
|
||||
errorCorrection: 'Q', // 25% error correction for mobile displays
|
||||
moduleSize: 3,
|
||||
quietZone: 4,
|
||||
backgroundColor: '#FFFFFF',
|
||||
foregroundColor: '#000000'
|
||||
}
|
||||
};
|
||||
|
||||
export class QRGenerator {
|
||||
private readonly signingSecret?: string;
|
||||
|
||||
constructor(signingSecret?: string) {
|
||||
this.signingSecret = signingSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code data for a ticket
|
||||
*/
|
||||
generateTicketQR(data: SignedTokenData, preset: keyof typeof QR_PRESETS = 'ticket'): QRGenerationResult {
|
||||
const options = QR_PRESETS[preset];
|
||||
|
||||
let qrData: string;
|
||||
const backupCode = this.generateBackupCode(data.ticketId);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + (data.expiresInDays || 30) * 24 * 60 * 60 * 1000);
|
||||
|
||||
if (options.format === 'simple') {
|
||||
qrData = `TICKET_${data.ticketId}`;
|
||||
} else {
|
||||
qrData = this.generateSignedToken(data, expiresAt);
|
||||
}
|
||||
|
||||
return {
|
||||
qrData,
|
||||
backupCode,
|
||||
metadata: {
|
||||
format: options.format,
|
||||
ticketId: data.ticketId,
|
||||
eventId: data.eventId,
|
||||
generatedAt: now.toISOString(),
|
||||
expiresAt: options.format === 'signed' ? expiresAt.toISOString() : undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate signed token QR data
|
||||
*/
|
||||
private generateSignedToken(data: SignedTokenData, expiresAt: Date): string {
|
||||
const payload = {
|
||||
v: 2,
|
||||
tid: data.ticketId,
|
||||
eid: data.eventId,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(expiresAt.getTime() / 1000),
|
||||
...(data.zone && { zone: data.zone }),
|
||||
...(data.seat && { seat: data.seat })
|
||||
};
|
||||
|
||||
const payloadB64 = this.base64UrlEncode(JSON.stringify(payload));
|
||||
const signature = this.generateSignature(payloadB64);
|
||||
|
||||
return `BCT.v2.${payloadB64}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HMAC-SHA256 signature
|
||||
*/
|
||||
private generateSignature(payload: string): string {
|
||||
if (!this.signingSecret) {
|
||||
throw new Error('Signing secret required for signed tokens');
|
||||
}
|
||||
|
||||
// In a real implementation, this would use crypto.subtle.sign or a crypto library
|
||||
// For demonstration, we'll create a deterministic signature
|
||||
return this.base64UrlEncode(this.simpleHmac(payload, this.signingSecret));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple HMAC implementation for demonstration
|
||||
* In production, use crypto.subtle.sign or a proper crypto library
|
||||
*/
|
||||
private simpleHmac(message: string, key: string): string {
|
||||
// This is a simplified version for demonstration only
|
||||
// Real implementation should use proper HMAC-SHA256
|
||||
const combined = `${key}.${message}.${key}`;
|
||||
let hash = 0;
|
||||
|
||||
for (let i = 0; i < combined.length; i++) {
|
||||
const char = combined.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
return Math.abs(hash).toString(16).padStart(8, '0').repeat(4).substring(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate backup code from ticket ID (last 8 characters)
|
||||
*/
|
||||
generateBackupCode(ticketId: string): string {
|
||||
if (!this.isValidUUID(ticketId)) {
|
||||
throw new Error('Invalid ticket ID format');
|
||||
}
|
||||
|
||||
const cleanId = ticketId.replace(/-/g, '');
|
||||
return cleanId.slice(-8).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code SVG
|
||||
*/
|
||||
generateQRSVG(
|
||||
data: string,
|
||||
options: Partial<QRGenerationOptions> = {}
|
||||
): string {
|
||||
const opts: QRGenerationOptions = {
|
||||
...QR_PRESETS.ticket,
|
||||
...options
|
||||
};
|
||||
|
||||
// This would normally use a QR code library like qrcode-generator or qrious
|
||||
// For demonstration, we'll create a placeholder SVG
|
||||
const size = 200;
|
||||
const moduleCount = Math.ceil(Math.sqrt(data.length * 8)); // Rough estimate
|
||||
const moduleSize = size / (moduleCount + opts.quietZone * 2);
|
||||
|
||||
return `
|
||||
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="${size}" height="${size}" fill="${opts.backgroundColor}"/>
|
||||
<g transform="translate(${opts.quietZone * moduleSize}, ${opts.quietZone * moduleSize})">
|
||||
${this.generateQRPattern(data, moduleCount, moduleSize, opts.foregroundColor)}
|
||||
</g>
|
||||
<text x="${size / 2}" y="${size - 5}" text-anchor="middle" font-size="10" fill="${opts.foregroundColor}" opacity="0.7">
|
||||
${this.generateBackupCode(data.includes('tid') ? data : 'sample-id')}
|
||||
</text>
|
||||
</svg>`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR pattern (simplified for demonstration)
|
||||
*/
|
||||
private generateQRPattern(data: string, moduleCount: number, moduleSize: number, color: string): string {
|
||||
let pattern = '';
|
||||
|
||||
// Create a simple pattern based on data hash
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
hash = ((hash << 5) - hash + data.charCodeAt(i)) & 0xffffffff;
|
||||
}
|
||||
|
||||
for (let row = 0; row < moduleCount; row++) {
|
||||
for (let col = 0; col < moduleCount; col++) {
|
||||
// Simple pseudo-random pattern based on position and data hash
|
||||
const shouldFill = ((row * moduleCount + col + hash) % 3) === 0;
|
||||
|
||||
if (shouldFill) {
|
||||
pattern += `<rect x="${col * moduleSize}" y="${row * moduleSize}" width="${moduleSize}" height="${moduleSize}" fill="${color}"/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encoding (without padding)
|
||||
*/
|
||||
private base64UrlEncode(str: string): string {
|
||||
return btoa(str)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate UUID format
|
||||
*/
|
||||
private isValidUUID(uuid: string): boolean {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create QR generator instance with signing secret from environment
|
||||
*/
|
||||
export function createQRGenerator(): QRGenerator {
|
||||
// In a real app, this would come from environment variables
|
||||
const signingSecret = import.meta.env?.VITE_QR_SIGNING_SECRET || 'demo-secret-key-for-testing-only';
|
||||
return new QRGenerator(signingSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ticket QR code with default settings
|
||||
*/
|
||||
export function generateTicketQR(ticketData: SignedTokenData): QRGenerationResult {
|
||||
const generator = createQRGenerator();
|
||||
return generator.generateTicketQR(ticketData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code for different output formats
|
||||
*/
|
||||
export function generateQRForFormat(
|
||||
ticketData: SignedTokenData,
|
||||
format: 'email' | 'print' | 'thermal' | 'wallet' | 'kiosk'
|
||||
): { qr: QRGenerationResult; svg: string } {
|
||||
const generator = createQRGenerator();
|
||||
|
||||
const presetMap: Record<typeof format, keyof typeof QR_PRESETS> = {
|
||||
email: 'email',
|
||||
print: 'ticket',
|
||||
thermal: 'thermal_printer',
|
||||
wallet: 'mobile_wallet',
|
||||
kiosk: 'ticket'
|
||||
};
|
||||
|
||||
const qr = generator.generateTicketQR(ticketData, presetMap[format]);
|
||||
const svg = generator.generateQRSVG(qr.qrData, QR_PRESETS[presetMap[format]]);
|
||||
|
||||
return { qr, svg };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate QR generation requirements
|
||||
*/
|
||||
export function validateQRData(data: SignedTokenData): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!data.ticketId?.trim()) {
|
||||
errors.push('Ticket ID is required');
|
||||
} else if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(data.ticketId)) {
|
||||
errors.push('Ticket ID must be a valid UUID');
|
||||
}
|
||||
|
||||
if (!data.eventId?.trim()) {
|
||||
errors.push('Event ID is required');
|
||||
}
|
||||
|
||||
if (data.expiresInDays !== undefined && (data.expiresInDays < 1 || data.expiresInDays > 365)) {
|
||||
errors.push('Expiration days must be between 1 and 365');
|
||||
}
|
||||
|
||||
if (data.zone && data.zone.length > 10) {
|
||||
errors.push('Zone identifier must be 10 characters or less');
|
||||
}
|
||||
|
||||
if (data.seat && data.seat.length > 20) {
|
||||
errors.push('Seat identifier must be 20 characters or less');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
353
reactrebuild0825/src/lib/qr-validator.ts
Normal file
353
reactrebuild0825/src/lib/qr-validator.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* QR Code Validation and Processing Utility
|
||||
* Supports both simple ticket IDs and signed tokens
|
||||
*/
|
||||
|
||||
export interface QRValidationResult {
|
||||
valid: boolean;
|
||||
format: 'simple' | 'signed' | 'unknown';
|
||||
ticketId?: string;
|
||||
eventId?: string;
|
||||
errorReason?: 'invalid_format' | 'expired' | 'signature_invalid' | 'malformed' | 'missing_data';
|
||||
metadata?: {
|
||||
version?: number;
|
||||
issuedAt?: number;
|
||||
expiresAt?: number;
|
||||
zone?: string;
|
||||
seat?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SignedTokenPayload {
|
||||
v: number; // Version
|
||||
tid: string; // Ticket ID
|
||||
eid: string; // Event ID
|
||||
iat: number; // Issued at
|
||||
exp: number; // Expires at
|
||||
zone?: string; // Ticket zone/section
|
||||
seat?: string; // Seat assignment
|
||||
}
|
||||
|
||||
export class QRValidator {
|
||||
private readonly signingSecret?: string;
|
||||
|
||||
constructor(signingSecret?: string) {
|
||||
this.signingSecret = signingSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and parse a QR code string
|
||||
*/
|
||||
validateQR(qrString: string): QRValidationResult {
|
||||
if (!qrString || typeof qrString !== 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
format: 'unknown',
|
||||
errorReason: 'invalid_format'
|
||||
};
|
||||
}
|
||||
|
||||
const trimmed = qrString.trim();
|
||||
|
||||
// Check for signed token format: BCT.v2.{payload}.{signature}
|
||||
if (trimmed.startsWith('BCT.v')) {
|
||||
return this.validateSignedToken(trimmed);
|
||||
}
|
||||
|
||||
// Check for simple ticket ID format: TICKET_{UUID}
|
||||
if (trimmed.startsWith('TICKET_')) {
|
||||
return this.validateSimpleTicketId(trimmed);
|
||||
}
|
||||
|
||||
// Check if it's just a UUID (legacy format)
|
||||
if (this.isValidUUID(trimmed)) {
|
||||
return {
|
||||
valid: true,
|
||||
format: 'simple',
|
||||
ticketId: trimmed
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
format: 'unknown',
|
||||
errorReason: 'invalid_format'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ticket ID from any valid QR format
|
||||
*/
|
||||
extractTicketId(qrString: string): string | null {
|
||||
const result = this.validateQR(qrString);
|
||||
return result.valid ? result.ticketId || null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate manual entry backup code
|
||||
* Backup code is last 8 characters of ticket UUID
|
||||
*/
|
||||
validateBackupCode(code: string): { valid: boolean; normalizedCode?: string } {
|
||||
if (!code || typeof code !== 'string') {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
// Remove any non-alphanumeric characters and convert to uppercase
|
||||
const normalized = code.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||
|
||||
// Must be exactly 8 characters
|
||||
if (normalized.length !== 8) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
// Must contain only valid hex characters or numbers
|
||||
if (!/^[0-9A-F]{8}$/.test(normalized)) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
normalizedCode: normalized
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate backup code from ticket ID
|
||||
*/
|
||||
generateBackupCode(ticketId: string): string | null {
|
||||
if (!this.isValidUUID(ticketId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove hyphens and take last 8 characters
|
||||
const cleanId = ticketId.replace(/-/g, '');
|
||||
return cleanId.slice(-8).toUpperCase();
|
||||
}
|
||||
|
||||
private validateSimpleTicketId(qrString: string): QRValidationResult {
|
||||
const ticketPrefix = 'TICKET_';
|
||||
const ticketId = qrString.substring(ticketPrefix.length);
|
||||
|
||||
if (!this.isValidUUID(ticketId)) {
|
||||
return {
|
||||
valid: false,
|
||||
format: 'simple',
|
||||
errorReason: 'invalid_format'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
format: 'simple',
|
||||
ticketId
|
||||
};
|
||||
}
|
||||
|
||||
private validateSignedToken(qrString: string): QRValidationResult {
|
||||
try {
|
||||
const parts = qrString.split('.');
|
||||
|
||||
if (parts.length !== 4) {
|
||||
return {
|
||||
valid: false,
|
||||
format: 'signed',
|
||||
errorReason: 'malformed'
|
||||
};
|
||||
}
|
||||
|
||||
const [prefix, version, payloadB64, signatureB64] = parts;
|
||||
|
||||
if (prefix !== 'BCT' || !version.startsWith('v')) {
|
||||
return {
|
||||
valid: false,
|
||||
format: 'signed',
|
||||
errorReason: 'invalid_format'
|
||||
};
|
||||
}
|
||||
|
||||
// Parse version number
|
||||
const versionNum = parseInt(version.substring(1), 10);
|
||||
if (isNaN(versionNum) || versionNum < 2) {
|
||||
return {
|
||||
valid: false,
|
||||
format: 'signed',
|
||||
errorReason: 'invalid_format'
|
||||
};
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
let payload: SignedTokenPayload;
|
||||
try {
|
||||
const payloadJson = this.base64Decode(payloadB64);
|
||||
payload = JSON.parse(payloadJson);
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
format: 'signed',
|
||||
errorReason: 'malformed'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate payload structure
|
||||
if (!payload.tid || !payload.eid || !payload.iat || !payload.exp) {
|
||||
return {
|
||||
valid: false,
|
||||
format: 'signed',
|
||||
errorReason: 'missing_data'
|
||||
};
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < now) {
|
||||
return {
|
||||
valid: false,
|
||||
format: 'signed',
|
||||
ticketId: payload.tid,
|
||||
eventId: payload.eid,
|
||||
errorReason: 'expired',
|
||||
metadata: {
|
||||
version: payload.v,
|
||||
issuedAt: payload.iat,
|
||||
expiresAt: payload.exp,
|
||||
zone: payload.zone,
|
||||
seat: payload.seat
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Verify signature if signing secret is available
|
||||
if (this.signingSecret) {
|
||||
const signatureValid = this.verifySignature(payloadB64, signatureB64);
|
||||
if (!signatureValid) {
|
||||
return {
|
||||
valid: false,
|
||||
format: 'signed',
|
||||
errorReason: 'signature_invalid'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
format: 'signed',
|
||||
ticketId: payload.tid,
|
||||
eventId: payload.eid,
|
||||
metadata: {
|
||||
version: payload.v,
|
||||
issuedAt: payload.iat,
|
||||
expiresAt: payload.exp,
|
||||
zone: payload.zone,
|
||||
seat: payload.seat
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
format: 'signed',
|
||||
errorReason: 'malformed'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private verifySignature(payloadB64: string, signatureB64: string): boolean {
|
||||
if (!this.signingSecret) {
|
||||
return false; // Cannot verify without secret
|
||||
}
|
||||
|
||||
try {
|
||||
// In a real implementation, this would use crypto.subtle.sign
|
||||
// For now, we'll simulate signature verification
|
||||
const expectedSignature = this.generateSignature(payloadB64);
|
||||
return this.constantTimeEqual(signatureB64, expectedSignature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private generateSignature(payload: string): string {
|
||||
// This is a simplified version for demonstration
|
||||
// Real implementation would use HMAC-SHA256
|
||||
if (!this.signingSecret) {
|
||||
throw new Error('No signing secret available');
|
||||
}
|
||||
|
||||
// Placeholder for actual HMAC-SHA256 implementation
|
||||
return btoa(`${payload}.${this.signingSecret}`).replace(/[^a-zA-Z0-9]/g, '').substring(0, 32);
|
||||
}
|
||||
|
||||
private constantTimeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
}
|
||||
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
private isValidUUID(uuid: string): boolean {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(uuid);
|
||||
}
|
||||
|
||||
private base64Decode(str: string): string {
|
||||
try {
|
||||
return atob(str.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
} catch {
|
||||
throw new Error('Invalid base64 string');
|
||||
}
|
||||
}
|
||||
|
||||
private base64Encode(str: string): string {
|
||||
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create QR validator instance with signing secret from environment
|
||||
*/
|
||||
export function createQRValidator(): QRValidator {
|
||||
// In a real app, this would come from environment variables
|
||||
const signingSecret = import.meta.env?.VITE_QR_SIGNING_SECRET;
|
||||
return new QRValidator(signingSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get user-friendly error messages
|
||||
*/
|
||||
export function getQRErrorMessage(result: QRValidationResult): string {
|
||||
if (result.valid) {
|
||||
return 'Valid ticket';
|
||||
}
|
||||
|
||||
switch (result.errorReason) {
|
||||
case 'invalid_format':
|
||||
return 'Invalid QR code format';
|
||||
case 'expired':
|
||||
return 'Ticket has expired';
|
||||
case 'signature_invalid':
|
||||
return 'Invalid or tampered ticket';
|
||||
case 'malformed':
|
||||
return 'Corrupted QR code data';
|
||||
case 'missing_data':
|
||||
return 'Incomplete ticket information';
|
||||
default:
|
||||
return 'Unknown error occurred';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format backup code with hyphens for display
|
||||
*/
|
||||
export function formatBackupCode(code: string): string {
|
||||
if (code.length !== 8) {
|
||||
return code;
|
||||
}
|
||||
|
||||
return `${code.substring(0, 4)}-${code.substring(4)}`;
|
||||
}
|
||||
341
reactrebuild0825/src/lib/sentry.ts
Normal file
341
reactrebuild0825/src/lib/sentry.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Sentry Error Tracking Configuration
|
||||
*
|
||||
* Centralized Sentry setup for error monitoring and performance tracking.
|
||||
* Special focus on scanner operations and user session context.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
// import { BrowserTracing } from '@sentry/integrations';
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
interface SentryConfig {
|
||||
dsn: string;
|
||||
environment: string;
|
||||
sampleRate: number;
|
||||
tracesSampleRate: number;
|
||||
release?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Sentry with configuration from environment variables
|
||||
*/
|
||||
export function initializeSentry(): void {
|
||||
const config: SentryConfig = {
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT || 'staging',
|
||||
sampleRate: parseFloat(import.meta.env.VITE_SENTRY_SAMPLE_RATE || '1.0'),
|
||||
tracesSampleRate: 0.2, // 20% sampling for performance monitoring
|
||||
release: import.meta.env.VITE_GIT_SHA || import.meta.env.VITE_APP_VERSION || '1.0.0',
|
||||
};
|
||||
|
||||
// Skip initialization if DSN is not configured or is a mock value
|
||||
if (!config.dsn || config.dsn.includes('mock')) {
|
||||
console.info('Sentry: Skipping initialization (no DSN or mock DSN detected)');
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn: config.dsn,
|
||||
environment: config.environment,
|
||||
release: config.release,
|
||||
sampleRate: config.sampleRate,
|
||||
tracesSampleRate: config.tracesSampleRate,
|
||||
|
||||
integrations: [
|
||||
// Enhanced browser tracking - commented out due to import issue
|
||||
// new BrowserTracing({
|
||||
// // Capture interactions on buttons, links, and inputs
|
||||
// tracingOrigins: ['localhost', 'staging.blackcanyontickets.com', 'portal.blackcanyontickets.com'],
|
||||
// enableLongTask: true,
|
||||
// enableInp: true,
|
||||
// }),
|
||||
],
|
||||
|
||||
// Performance monitoring
|
||||
tracesSampler: (samplingContext) => {
|
||||
// Higher sampling for scanner operations
|
||||
if (samplingContext.transactionContext.name?.includes('scanner')) {
|
||||
return 0.5; // 50% sampling for scanner operations
|
||||
}
|
||||
|
||||
// Lower sampling for regular operations
|
||||
return 0.1; // 10% sampling for other operations
|
||||
},
|
||||
|
||||
// Error filtering and PII masking
|
||||
beforeSend: (event, hint) => {
|
||||
// Skip common development errors
|
||||
if (config.environment === 'development') {
|
||||
// Filter out hot reload errors
|
||||
if (event.exception?.values?.[0]?.value?.includes('ChunkLoadError')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter out WebSocket connection errors in dev
|
||||
if (event.exception?.values?.[0]?.value?.includes('WebSocket')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Mask PII data in event
|
||||
event = maskPII(event);
|
||||
|
||||
// Always capture scanner-related errors
|
||||
if (event.tags?.feature === 'scanner') {
|
||||
return event;
|
||||
}
|
||||
|
||||
// Filter out network errors that aren't actionable
|
||||
if (event.exception?.values?.[0]?.type === 'TypeError' &&
|
||||
event.exception?.values?.[0]?.value?.includes('fetch')) {
|
||||
// Only send if it's not a timeout
|
||||
if (!event.exception?.values?.[0]?.value?.includes('timeout')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
|
||||
// Enhanced context capture
|
||||
initialScope: {
|
||||
tags: {
|
||||
component: 'react-app',
|
||||
platform: 'web',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.info('Sentry: Initialized successfully', {
|
||||
environment: config.environment,
|
||||
release: config.release,
|
||||
sampleRate: config.sampleRate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user context for error tracking
|
||||
*/
|
||||
export function setSentryUser(user: {
|
||||
id: string;
|
||||
email?: string;
|
||||
organizationId?: string;
|
||||
role?: string;
|
||||
}): void {
|
||||
Sentry.setUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
Sentry.setTags({
|
||||
'user.organization': user.organizationId,
|
||||
'user.role': user.role,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set scanner session context
|
||||
*/
|
||||
export function setScannerContext(context: {
|
||||
sessionId: string;
|
||||
deviceId: string;
|
||||
eventId?: string;
|
||||
organizationId?: string;
|
||||
}): void {
|
||||
Sentry.setContext('scanner', {
|
||||
sessionId: context.sessionId,
|
||||
deviceId: context.deviceId,
|
||||
eventId: context.eventId,
|
||||
organizationId: context.organizationId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
Sentry.setTags({
|
||||
'scanner.session': context.sessionId,
|
||||
'scanner.device': context.deviceId.split('_')[1]?.substring(0, 8) || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture scanner-specific errors with enhanced context
|
||||
*/
|
||||
export function captureScannerError(
|
||||
error: Error,
|
||||
context: {
|
||||
operation: string;
|
||||
qr?: string;
|
||||
sessionId?: string;
|
||||
deviceId?: string;
|
||||
eventId?: string;
|
||||
additionalData?: Record<string, any>;
|
||||
}
|
||||
): void {
|
||||
Sentry.withScope((scope) => {
|
||||
// Set error context
|
||||
scope.setTag('feature', 'scanner');
|
||||
scope.setTag('scanner.operation', context.operation);
|
||||
|
||||
if (context.sessionId) {
|
||||
scope.setTag('scanner.session', context.sessionId);
|
||||
}
|
||||
|
||||
if (context.deviceId) {
|
||||
scope.setTag('scanner.device', context.deviceId.split('_')[1]?.substring(0, 8) || 'unknown');
|
||||
}
|
||||
|
||||
// Set scanner-specific context
|
||||
scope.setContext('scanner_error', {
|
||||
operation: context.operation,
|
||||
qr_masked: context.qr ? maskSensitiveData(context.qr) : undefined,
|
||||
eventId: context.eventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
...context.additionalData,
|
||||
});
|
||||
|
||||
// Set appropriate level based on operation
|
||||
const level = context.operation.includes('critical') ? 'error' : 'warning';
|
||||
scope.setLevel(level);
|
||||
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture performance data for scanner operations
|
||||
*/
|
||||
export function captureScannerPerformance(
|
||||
operation: string,
|
||||
duration: number,
|
||||
metadata?: Record<string, any>
|
||||
): void {
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'scanner.performance',
|
||||
message: `${operation} completed in ${duration}ms`,
|
||||
level: 'info',
|
||||
data: {
|
||||
operation,
|
||||
duration,
|
||||
...metadata,
|
||||
},
|
||||
});
|
||||
|
||||
// Create custom transaction for slow operations
|
||||
if (duration > 2000) { // If operation takes longer than 2 seconds
|
||||
const transaction = Sentry.startTransaction({
|
||||
name: `scanner.${operation}`,
|
||||
op: 'scanner_operation',
|
||||
});
|
||||
|
||||
transaction.setData('duration', duration);
|
||||
transaction.setData('metadata', metadata);
|
||||
transaction.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive data in QR codes or other sensitive strings
|
||||
*/
|
||||
function maskSensitiveData(data: string): string {
|
||||
if (data.length <= 8) {
|
||||
return '***';
|
||||
}
|
||||
|
||||
// Show first 4 and last 4 characters, mask the middle
|
||||
const start = data.substring(0, 4);
|
||||
const end = data.substring(data.length - 4);
|
||||
const maskLength = data.length - 8;
|
||||
const mask = '*'.repeat(Math.min(maskLength, 20)); // Cap mask length for readability
|
||||
|
||||
return `${start}${mask}${end}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask PII (personally identifiable information) in Sentry events
|
||||
*/
|
||||
function maskPII(event: any): any {
|
||||
// Email pattern
|
||||
const emailPattern = /[\w\.-]+@[\w\.-]+\.\w+/g;
|
||||
// Token patterns (JWT, API keys, etc.)
|
||||
const tokenPattern = /((?:sk_|pk_|eyJ)[a-zA-Z0-9_\-\.]+)/g;
|
||||
// Card number pattern
|
||||
const cardPattern = /\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b/g;
|
||||
// Phone number pattern
|
||||
const phonePattern = /\b\d{3}[\-\.]?\d{3}[\-\.]?\d{4}\b/g;
|
||||
|
||||
function maskString(str: string): string {
|
||||
return str
|
||||
.replace(emailPattern, (match) => {
|
||||
const [localPart, domain] = match.split('@');
|
||||
const maskedLocal = localPart.length > 2
|
||||
? localPart.substring(0, 2) + '***'
|
||||
: '***';
|
||||
return `${maskedLocal}@${domain}`;
|
||||
})
|
||||
.replace(tokenPattern, (match) => maskSensitiveData(match))
|
||||
.replace(cardPattern, '**** **** **** ****')
|
||||
.replace(phonePattern, '***-***-****');
|
||||
}
|
||||
|
||||
function maskObject(obj: any): any {
|
||||
if (typeof obj === 'string') {
|
||||
return maskString(obj);
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(maskObject);
|
||||
}
|
||||
|
||||
if (obj && typeof obj === 'object') {
|
||||
const masked: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Special handling for known sensitive fields
|
||||
if (['email', 'token', 'password', 'secret', 'key'].includes(key.toLowerCase())) {
|
||||
masked[key] = typeof value === 'string' ? maskSensitiveData(value) : '[MASKED]';
|
||||
} else {
|
||||
masked[key] = maskObject(value);
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
return maskObject(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced breadcrumb for scanner operations
|
||||
*/
|
||||
export function addScannerBreadcrumb(
|
||||
operation: string,
|
||||
data?: Record<string, any>
|
||||
): void {
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'scanner',
|
||||
message: `Scanner: ${operation}`,
|
||||
level: 'info',
|
||||
data: {
|
||||
timestamp: new Date().toISOString(),
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* React Error Boundary component with Sentry integration
|
||||
*/
|
||||
export const SentryErrorBoundary = Sentry.ErrorBoundary;
|
||||
|
||||
// Re-export commonly used Sentry functions
|
||||
export { Sentry };
|
||||
export const {captureException} = Sentry;
|
||||
export const {captureMessage} = Sentry;
|
||||
export const {addBreadcrumb} = Sentry;
|
||||
export const {setContext} = Sentry;
|
||||
export const {setTag} = Sentry;
|
||||
export const {setTags} = Sentry;
|
||||
export const {setUser} = Sentry;
|
||||
export const {withScope} = Sentry;
|
||||
76
reactrebuild0825/src/lib/utils.ts
Normal file
76
reactrebuild0825/src/lib/utils.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Utility function to merge class names with Tailwind CSS support
|
||||
* Combines clsx for conditional classes and tailwind-merge for Tailwind conflicts
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number as currency (USD)
|
||||
*/
|
||||
export function formatCurrency(cents: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string for display
|
||||
*/
|
||||
export function formatDate(dateString: string, options?: Intl.DateTimeFormatOptions): string {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
...options,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function to limit how often a function can be called
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random ID string
|
||||
*/
|
||||
export function generateId(prefix = 'id'): string {
|
||||
return `${prefix}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep utility for async operations
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize the first letter of a string
|
||||
*/
|
||||
export function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to a specified length
|
||||
*/
|
||||
export function truncate(text: string, length: number, suffix = '...'): string {
|
||||
if (text.length <= length) return text;
|
||||
return text.slice(0, length) + suffix;
|
||||
}
|
||||
Reference in New Issue
Block a user