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,627 @@
/**
* Mobile UX Tests - Mobile-Specific User Experience Testing
* Tests touch interactions, rotation handling, camera switching,
* torch functionality, and permission flows for mobile devices
*/
import { test, expect } from '@playwright/test';
test.describe('Mobile Touch Interactions', () => {
const testEventId = 'evt-001';
test.beforeEach(async ({ page, context }) => {
// Set mobile viewport
await page.setViewportSize({ width: 375, height: 667 }); // iPhone SE
await context.grantPermissions(['camera']);
await page.goto('/login');
await page.fill('[name="email"]', 'staff@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
await page.goto(`/scan?eventId=${testEventId}`);
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
});
test('should handle touch interactions on all buttons', async ({ page }) => {
// Test settings button touch
const settingsButton = page.locator('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
await expect(settingsButton).toBeVisible();
// Touch target should be large enough (minimum 44px)
const buttonBox = await settingsButton.boundingBox();
expect(buttonBox?.width).toBeGreaterThanOrEqual(44);
expect(buttonBox?.height).toBeGreaterThanOrEqual(44);
// Touch interaction should work
await settingsButton.tap();
await expect(page.locator('text=Gate/Zone')).toBeVisible();
// Close settings with touch
await settingsButton.tap();
await expect(page.locator('text=Gate/Zone')).not.toBeVisible();
// Test torch button if present
const torchButton = page.locator('button:has([data-testid="flashlight-icon"]), button:has(.lucide-flashlight)');
if (await torchButton.isVisible()) {
const torchBox = await torchButton.boundingBox();
expect(torchBox?.width).toBeGreaterThanOrEqual(44);
expect(torchBox?.height).toBeGreaterThanOrEqual(44);
await torchButton.tap();
// Should handle torch toggle without errors
}
});
test('should provide tactile feedback for scan results', async ({ page }) => {
// Test vibration API integration
const vibrationSupported = await page.evaluate(() => 'vibrate' in navigator);
if (vibrationSupported) {
// Simulate successful scan with vibration feedback
await page.evaluate(() => {
// Test vibration pattern for success
navigator.vibrate([50, 30, 50]); // Short-medium-short pattern
window.dispatchEvent(new CustomEvent('mock-scan-result', {
detail: {
qr: 'VIBRATION_TEST_SUCCESS',
status: 'success',
message: 'Valid ticket',
timestamp: Date.now(),
vibrationPattern: [50, 30, 50]
}
}));
});
await page.waitForTimeout(500);
// Simulate error scan with different vibration
await page.evaluate(() => {
// Test vibration pattern for error
navigator.vibrate([200]); // Single long vibration for error
window.dispatchEvent(new CustomEvent('mock-scan-result', {
detail: {
qr: 'VIBRATION_TEST_ERROR',
status: 'error',
message: 'Invalid ticket',
timestamp: Date.now(),
vibrationPattern: [200]
}
}));
});
await page.waitForTimeout(500);
}
// Scanner should remain functional regardless of vibration support
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
});
test('should handle touch scrolling in settings panel', async ({ page }) => {
// Open settings
await page.locator('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)').tap();
await expect(page.locator('text=Gate/Zone')).toBeVisible();
// Test scrolling behavior if settings panel has scrollable content
const settingsPanel = page.locator('.settings-panel, [data-testid="settings-panel"]').first();
if (await settingsPanel.isVisible()) {
// Try to scroll within settings panel
await settingsPanel.hover();
await page.mouse.wheel(0, 100);
// Should handle scroll without affecting main page
await expect(page.locator('text=Gate/Zone')).toBeVisible();
}
// Test input focus behavior
const gateInput = page.locator('input[placeholder*="Gate"]');
await gateInput.tap();
await expect(gateInput).toBeFocused();
// Virtual keyboard shouldn't break layout
await gateInput.fill('Gate A - Touch Test');
await expect(gateInput).toHaveValue('Gate A - Touch Test');
// Dismiss keyboard by tapping outside
await page.locator('h1').tap();
await expect(gateInput).not.toBeFocused();
});
test('should handle touch gestures and prevent zoom', async ({ page }) => {
// Test that pinch-to-zoom is disabled for scanner area
const videoElement = page.locator('video');
await expect(videoElement).toBeVisible();
// Check viewport meta tag prevents zooming
const viewportMeta = await page.locator('meta[name="viewport"]').getAttribute('content');
expect(viewportMeta).toContain('user-scalable=no');
// Test touch events don't interfere with scanning
const scannerArea = page.locator('.scanner-frame, [data-testid="scanner-frame"]').first();
if (await scannerArea.isVisible()) {
// Simulate touch on scanner area
await scannerArea.tap();
// Should not zoom or cause unwanted interactions
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
}
// Test swipe gestures don't interfere
await page.touchscreen.tap(200, 300);
await page.waitForTimeout(100);
// Scanner should remain stable
await expect(page.locator('video')).toBeVisible();
});
});
test.describe('Device Orientation Handling', () => {
const testEventId = 'evt-001';
test.beforeEach(async ({ page, context }) => {
await context.grantPermissions(['camera']);
await page.goto('/login');
await page.fill('[name="email"]', 'staff@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
await page.goto(`/scan?eventId=${testEventId}`);
});
test('should adapt layout for portrait orientation', async ({ page }) => {
// Set portrait mobile viewport
await page.setViewportSize({ width: 375, height: 667 });
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
// Video should be properly sized for portrait
const video = page.locator('video');
await expect(video).toBeVisible();
const videoBox = await video.boundingBox();
expect(videoBox?.height).toBeGreaterThan(videoBox?.width || 0);
// Settings button should be accessible
const settingsButton = page.locator('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
await expect(settingsButton).toBeVisible();
// Stats area should be visible
await expect(page.locator('text=Online')).toBeVisible();
await expect(page.locator('text=Total:')).toBeVisible();
});
test('should adapt layout for landscape orientation', async ({ page }) => {
// Set landscape mobile viewport
await page.setViewportSize({ width: 667, height: 375 });
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
// Video should adapt to landscape
const video = page.locator('video');
await expect(video).toBeVisible();
const videoBox = await video.boundingBox();
expect(videoBox?.width).toBeGreaterThan(videoBox?.height || 0);
// All critical elements should remain accessible in landscape
await expect(page.locator('text=Online')).toBeVisible();
// Settings should still be accessible
const settingsButton = page.locator('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
await settingsButton.tap();
await expect(page.locator('text=Gate/Zone')).toBeVisible();
// Settings panel should fit in landscape
const gateInput = page.locator('input[placeholder*="Gate"]');
await expect(gateInput).toBeVisible();
const inputBox = await gateInput.boundingBox();
expect(inputBox?.y).toBeGreaterThan(0); // Should be within viewport
});
test('should handle orientation changes smoothly', async ({ page }) => {
// Start in portrait
await page.setViewportSize({ width: 375, height: 667 });
await expect(page.locator('video')).toBeVisible();
// Open settings in portrait
await page.locator('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)').tap();
await page.fill('input[placeholder*="Gate"]', 'Orientation Test');
// Rotate to landscape
await page.setViewportSize({ width: 667, height: 375 });
await page.waitForTimeout(1000); // Allow for orientation change
// Settings should remain open and functional
await expect(page.locator('input[placeholder*="Gate"]')).toHaveValue('Orientation Test');
await expect(page.locator('text=Gate/Zone')).toBeVisible();
// Video should still be working
await expect(page.locator('video')).toBeVisible();
// Rotate back to portrait
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForTimeout(1000);
// Everything should still work
await expect(page.locator('video')).toBeVisible();
await expect(page.locator('input[placeholder*="Gate"]')).toHaveValue('Orientation Test');
});
test('should lock orientation when supported', async ({ page }) => {
// Test orientation lock API if available
await page.setViewportSize({ width: 375, height: 667 });
const orientationLockSupported = await page.evaluate(async () => {
if ('orientation' in screen && 'lock' in screen.orientation) {
try {
await screen.orientation.lock('portrait');
return { supported: true, locked: true };
} catch (error) {
return { supported: true, locked: false, error: error.message };
}
}
return { supported: false };
});
console.log('Orientation lock support:', orientationLockSupported);
// Scanner should work regardless of orientation lock support
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
await expect(page.locator('video')).toBeVisible();
});
});
test.describe('Camera Switching and Controls', () => {
const testEventId = 'evt-001';
test.beforeEach(async ({ page, context }) => {
await page.setViewportSize({ width: 375, height: 667 });
await context.grantPermissions(['camera']);
await page.goto('/login');
await page.fill('[name="email"]', 'staff@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
await page.goto(`/scan?eventId=${testEventId}`);
});
test('should detect available cameras', async ({ page }) => {
const cameraInfo = await page.evaluate(async () => {
if ('mediaDevices' in navigator && 'enumerateDevices' in navigator.mediaDevices) {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter(device => device.kind === 'videoinput');
return {
supported: true,
cameraCount: cameras.length,
cameras: cameras.map(camera => ({
deviceId: camera.deviceId,
label: camera.label,
groupId: camera.groupId
}))
};
} catch (error) {
return { supported: true, error: error.message };
}
}
return { supported: false };
});
console.log('Camera detection:', cameraInfo);
if (cameraInfo.supported && cameraInfo.cameraCount > 0) {
expect(cameraInfo.cameraCount).toBeGreaterThan(0);
}
// Scanner should work with whatever cameras are available
await expect(page.locator('video')).toBeVisible({ timeout: 10000 });
});
test('should switch between front and rear cameras', async ({ page }) => {
// Check if camera switching UI exists
const cameraSwitchButton = page.locator('button:has([data-testid="camera-switch"]), button:has(.lucide-camera-switch)');
if (await cameraSwitchButton.isVisible()) {
// Test camera switching
await cameraSwitchButton.tap();
await page.waitForTimeout(2000); // Allow camera switch time
// Video should still be working after switch
await expect(page.locator('video')).toBeVisible();
// Try switching back
await cameraSwitchButton.tap();
await page.waitForTimeout(2000);
await expect(page.locator('video')).toBeVisible();
} else {
// If no camera switch button, verify single camera works
await expect(page.locator('video')).toBeVisible();
}
});
test('should handle camera constraints for mobile', async ({ page }) => {
// Test mobile-specific camera constraints
const cameraConstraints = await page.evaluate(async () => {
if ('mediaDevices' in navigator) {
try {
const constraints = {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'environment', // Rear camera preferred for scanning
frameRate: { ideal: 30, max: 60 }
}
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const track = stream.getVideoTracks()[0];
const settings = track.getSettings();
// Clean up
stream.getTracks().forEach(t => t.stop());
return {
success: true,
settings: {
width: settings.width,
height: settings.height,
frameRate: settings.frameRate,
facingMode: settings.facingMode
}
};
} catch (error) {
return { success: false, error: error.message };
}
}
return { success: false, error: 'Media devices not supported' };
});
console.log('Camera constraints test:', cameraConstraints);
// Scanner should work regardless of specific constraint support
await expect(page.locator('video')).toBeVisible();
});
});
test.describe('Torch/Flashlight Functionality', () => {
const testEventId = 'evt-001';
test.beforeEach(async ({ page, context }) => {
await page.setViewportSize({ width: 375, height: 667 });
await context.grantPermissions(['camera']);
await page.goto('/login');
await page.fill('[name="email"]', 'staff@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
await page.goto(`/scan?eventId=${testEventId}`);
});
test('should detect torch capability', async ({ page }) => {
await expect(page.locator('video')).toBeVisible({ timeout: 10000 });
const torchCapability = await page.evaluate(async () => {
if ('mediaDevices' in navigator) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const track = stream.getVideoTracks()[0];
const capabilities = track.getCapabilities();
// Clean up
stream.getTracks().forEach(t => t.stop());
return {
supported: 'torch' in capabilities,
capabilities: capabilities.torch || false
};
} catch (error) {
return { supported: false, error: error.message };
}
}
return { supported: false };
});
console.log('Torch capability:', torchCapability);
// Check if torch button is visible based on capability
const torchButton = page.locator('button:has([data-testid="flashlight-icon"]), button:has(.lucide-flashlight)');
if (torchCapability.supported) {
// Should show torch button if supported
await expect(torchButton).toBeVisible();
}
// Scanner should work regardless of torch support
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
});
test('should toggle torch on/off', async ({ page }) => {
await expect(page.locator('video')).toBeVisible({ timeout: 10000 });
const torchButton = page.locator('button:has([data-testid="flashlight-icon"]), button:has(.lucide-flashlight)');
if (await torchButton.isVisible()) {
// Test torch toggle
await torchButton.tap();
await page.waitForTimeout(500);
// Verify torch state change (visual indication would vary)
// In a real test, you might check for class changes or aria-pressed attributes
const isPressed = await torchButton.evaluate(el =>
el.getAttribute('aria-pressed') === 'true' ||
el.classList.contains('active') ||
el.classList.contains('pressed')
);
// Toggle back off
await torchButton.tap();
await page.waitForTimeout(500);
// Should toggle state
const isPressedAfter = await torchButton.evaluate(el =>
el.getAttribute('aria-pressed') === 'true' ||
el.classList.contains('active') ||
el.classList.contains('pressed')
);
expect(isPressed).not.toBe(isPressedAfter);
}
// Scanner functionality should not be affected
await expect(page.locator('video')).toBeVisible();
});
test('should provide visual feedback for torch state', async ({ page }) => {
const torchButton = page.locator('button:has([data-testid="flashlight-icon"]), button:has(.lucide-flashlight)');
if (await torchButton.isVisible()) {
// Get initial visual state
const initialClasses = await torchButton.getAttribute('class');
const initialAriaPressed = await torchButton.getAttribute('aria-pressed');
// Toggle torch
await torchButton.tap();
await page.waitForTimeout(300);
// Check for visual changes
const afterClasses = await torchButton.getAttribute('class');
const afterAriaPressed = await torchButton.getAttribute('aria-pressed');
// Should have some visual indication of state change
const hasVisualChange =
initialClasses !== afterClasses ||
initialAriaPressed !== afterAriaPressed;
expect(hasVisualChange).toBe(true);
// Button should still be accessible
const buttonBox = await torchButton.boundingBox();
expect(buttonBox?.width).toBeGreaterThanOrEqual(44);
expect(buttonBox?.height).toBeGreaterThanOrEqual(44);
}
});
});
test.describe('Permission Flows', () => {
const testEventId = 'evt-001';
test('should handle camera permission denied gracefully', async ({ page, context }) => {
await page.setViewportSize({ width: 375, height: 667 });
// Don't grant camera permission
await page.goto('/login');
await page.fill('[name="email"]', 'staff@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
await page.goto(`/scan?eventId=${testEventId}`);
// Should show permission request or error message
await page.waitForTimeout(3000);
// Scanner should still load the UI
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
// Should show appropriate error state
// (Implementation would show permission denied message or retry button)
// Check if there's a retry/permission button
const permissionButton = page.locator('button:has-text("Grant Camera Permission"), button:has-text("Retry"), button:has-text("Enable Camera")');
if (await permissionButton.isVisible()) {
await permissionButton.tap();
await page.waitForTimeout(2000);
}
});
test('should request permissions on first visit', async ({ page, context }) => {
await page.setViewportSize({ width: 375, height: 667 });
// Clear all permissions first
await context.clearPermissions();
await page.goto('/login');
await page.fill('[name="email"]', 'staff@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
// Navigate to scanner - should trigger permission request
await page.goto(`/scan?eventId=${testEventId}`);
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
// Grant permission when prompted
await context.grantPermissions(['camera']);
// Should eventually show video
await expect(page.locator('video')).toBeVisible({ timeout: 10000 });
});
test('should handle notification permissions for alerts', async ({ page, context }) => {
await page.setViewportSize({ width: 375, height: 667 });
await context.grantPermissions(['camera']);
await page.goto('/login');
await page.fill('[name="email"]', 'staff@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
await page.goto(`/scan?eventId=${testEventId}`);
// Test notification permission request
const notificationPermission = await page.evaluate(async () => {
if ('Notification' in window) {
const permission = await Notification.requestPermission();
return { supported: true, permission };
}
return { supported: false };
});
console.log('Notification permission:', notificationPermission);
if (notificationPermission.supported) {
expect(['granted', 'denied', 'default']).toContain(notificationPermission.permission);
}
// Scanner should work regardless of notification permission
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
});
test('should show helpful permission instructions', async ({ page, context }) => {
await page.setViewportSize({ width: 375, height: 667 });
// Start without camera permission
await context.clearPermissions();
await page.goto('/login');
await page.fill('[name="email"]', 'staff@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
await page.goto(`/scan?eventId=${testEventId}`);
await page.waitForTimeout(3000);
// Should show instructions or help text about camera permissions
// (Implementation would show helpful guidance)
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
// Look for permission-related text
const hasPermissionGuidance = await page.evaluate(() => {
const text = document.body.innerText.toLowerCase();
return text.includes('camera') && (
text.includes('permission') ||
text.includes('access') ||
text.includes('allow') ||
text.includes('enable')
);
});
// Should provide some guidance about permissions
if (!hasPermissionGuidance) {
console.log('Note: Consider adding permission guidance text for better UX');
}
});
});