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:
2025-08-26 09:25:10 -06:00
parent d5c3953888
commit aa81eb5adb
438 changed files with 90509 additions and 2787 deletions

View 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;
}

View 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 = () => () => {};

View 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
};
}

View 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)}`;
}

View 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;

View 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;
}