diff --git a/frontend/dist/index.html b/frontend/dist/index.html index bd667ff..f360e48 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -4,6 +4,7 @@ BabelBridge + @@ -12,7 +13,32 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/frontend/index.html b/frontend/index.html index 2118d42..64677e8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,7 @@ BabelBridge + @@ -12,6 +13,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 1711025..5025b83 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -1,21 +1,38 @@ { - "name": "BabelBridge", + "name": "BabelBridge - Modern Translation Tool", "short_name": "BabelBridge", - "description": "A modern web-based translation tool with live language identification and pluggable AI backends", + "description": "A modern web-based translation tool with live language identification and pluggable AI backends. Translate text seamlessly with intelligent context preservation.", "start_url": "/", "display": "standalone", - "background_color": "#0f1220", + "background_color": "#0f172a", "theme_color": "#6366F1", "orientation": "portrait-primary", + "scope": "/", + "lang": "en", "icons": [ { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" + }, + { + "src": "/og-image.svg", + "sizes": "1200x630", + "type": "image/svg+xml", + "purpose": "any" + } + ], + "categories": ["productivity", "utilities", "education"], + "keywords": ["translation", "AI", "language", "translate", "babel", "bridge", "multilingual"], + "screenshots": [ + { + "src": "/og-image.svg", + "sizes": "1200x630", + "type": "image/svg+xml", + "label": "BabelBridge main interface" } ], - "categories": ["productivity", "utilities"], "shortcuts": [ { "name": "New Translation", @@ -30,6 +47,8 @@ } ] } - ] + ], + "related_applications": [], + "prefer_related_applications": false } diff --git a/frontend/public/og-image.svg b/frontend/public/og-image.svg new file mode 100644 index 0000000..8d27947 --- /dev/null +++ b/frontend/public/og-image.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BabelBridge + + + + + Modern Translation Tool + + + + + AI-powered translation with live language + identification and context preservation + + + + + + Intelligent context preservation + + + Live language identification + + + Pluggable AI backends + + + + + + + + + + + diff --git a/frontend/src/components/AddToAppButton.tsx b/frontend/src/components/AddToAppButton.tsx index cb03671..1dbd155 100644 --- a/frontend/src/components/AddToAppButton.tsx +++ b/frontend/src/components/AddToAppButton.tsx @@ -11,16 +11,18 @@ export function AddToAppButton() { const [deferredPrompt, setDeferredPrompt] = useState(null) const [showIOSInstructions, setShowIOSInstructions] = useState(false) const [isInstallable, setIsInstallable] = useState(false) + const [showDebugInfo, setShowDebugInfo] = useState(false) useEffect(() => { const handleBeforeInstallPrompt = (e: Event) => { - // Prevent the mini-infobar from appearing on mobile + console.log('PWA: beforeinstallprompt event fired') e.preventDefault() setDeferredPrompt(e as BeforeInstallPromptEvent) setIsInstallable(true) } const handleAppInstalled = () => { + console.log('PWA: app installed') setIsInstallable(false) setDeferredPrompt(null) } @@ -28,9 +30,32 @@ export function AddToAppButton() { window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) window.addEventListener('appinstalled', handleAppInstalled) + // Debug logging + console.log('PWA: Component mounted, checking conditions...') + console.log('PWA: isIOS =', isIOS()) + console.log('PWA: isInStandaloneMode =', isInStandaloneMode()) + console.log('PWA: hasBeforeInstallPrompt =', 'onbeforeinstallprompt' in window) + console.log('PWA: hostname =', window.location.hostname) + + // For localhost development: if no beforeinstallprompt after 2 seconds, + // assume it's available for testing (but only on localhost) + const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' + const isDev = process.env.NODE_ENV === 'development' + + let timeoutId: NodeJS.Timeout | null = null + if (isDev && isLocalhost) { + timeoutId = setTimeout(() => { + if (!isInstallable && !isIOS()) { + console.log('PWA: No beforeinstallprompt after 2s, enabling for localhost testing') + setIsInstallable(true) + } + }, 2000) + } + return () => { window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) window.removeEventListener('appinstalled', handleAppInstalled) + if (timeoutId) clearTimeout(timeoutId) } }, []) @@ -39,8 +64,14 @@ export function AddToAppButton() { } const isInStandaloneMode = () => { - return window.matchMedia('(display-mode: standalone)').matches || - (window.navigator as any).standalone === true + try { + const matchMediaResult = window.matchMedia('(display-mode: standalone)') + return (matchMediaResult && matchMediaResult.matches) || + (window.navigator as any).standalone === true + } catch (e) { + // Fallback for test environments or browsers without matchMedia + return (window.navigator as any).standalone === true + } } const handleInstallClick = async () => { @@ -59,11 +90,27 @@ export function AddToAppButton() { } } - // Don't show the button if already installed or not installable - if (isInStandaloneMode() || (!isInstallable && !isIOS())) { + // Don't show the button if already installed + if (isInStandaloneMode()) { + console.log('PWA: Button hidden - already in standalone mode') return null } + // Show button if: + // 1. Actually installable (beforeinstallprompt fired), OR + // 2. iOS device (can always install via Safari), OR + // 3. Development mode AND localhost (for testing) + const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' + const isDev = process.env.NODE_ENV === 'development' + const shouldShowButton = isInstallable || isIOS() || (isDev && isLocalhost) + + if (!shouldShowButton) { + console.log('PWA: Button hidden - not installable, not iOS, not dev+localhost') + return null + } + + console.log('PWA: Button will show - installable:', isInstallable, 'iOS:', isIOS(), 'dev+localhost:', isDev && isLocalhost) + return ( <> ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), +})) + +// Set up global mocks before all tests +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: mockMatchMedia, +}) + +describe('AddToAppButton', () => { + let mockUserAgent: string + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks() + mockMatchMedia.mockClear() + + // Store original userAgent + mockUserAgent = navigator.userAgent + + // Mock console.log to avoid test noise + vi.spyOn(console, 'log').mockImplementation(() => {}) + + // Reset event listeners + window.removeEventListener = vi.fn() + window.addEventListener = vi.fn() + + // Set NODE_ENV for testing + process.env.NODE_ENV = 'development' + }) + + afterEach(() => { + // Restore userAgent + Object.defineProperty(navigator, 'userAgent', { + value: mockUserAgent, + configurable: true + }) + + // Restore NODE_ENV + process.env.NODE_ENV = originalEnv + + vi.restoreAllMocks() + }) + + const mockUserAgentAs = (userAgent: string) => { + Object.defineProperty(navigator, 'userAgent', { + value: userAgent, + configurable: true + }) + } + + const mockStandaloneMode = (isStandalone: boolean) => { + mockMatchMedia.mockImplementation(query => { + if (query === '(display-mode: standalone)') { + return { + matches: isStandalone, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + } + } + return { + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + } + }) + } + + describe('Component Rendering', () => { + it('renders without crashing', () => { + expect(() => render()).not.toThrow() + }) + + it('shows button in development mode', () => { + // Ensure development mode is set + process.env.NODE_ENV = 'development' + + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByText(/Add to Desktop/)).toBeInTheDocument() + }) + + it('does not show button when in standalone mode', () => { + mockStandaloneMode(true) + render() + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + }) + + describe('iOS Detection', () => { + it('shows iOS-specific button text and icon for iPhone', () => { + mockUserAgentAs('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)') + process.env.NODE_ENV = 'development' + + render() + + expect(screen.getByText(/Add to Home Screen/)).toBeInTheDocument() + expect(screen.getByTestId('PhoneIphoneIcon')).toBeInTheDocument() + }) + + it('shows iOS-specific button text for iPad', () => { + mockUserAgentAs('Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X)') + process.env.NODE_ENV = 'development' + + render() + + expect(screen.getByText(/Add to Home Screen/)).toBeInTheDocument() + }) + + it('shows desktop button text for non-iOS devices', () => { + mockUserAgentAs('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36') + process.env.NODE_ENV = 'development' + + render() + + expect(screen.getByText(/Add to Desktop/)).toBeInTheDocument() + expect(screen.getByTestId('LaptopMacIcon')).toBeInTheDocument() + }) + }) + + describe('Install Prompt Handling', () => { + let mockBeforeInstallPromptEvent: any + + beforeEach(() => { + mockBeforeInstallPromptEvent = { + preventDefault: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + userChoice: Promise.resolve({ outcome: 'accepted' }) + } + }) + + it('sets installable state when beforeinstallprompt fires', async () => { + const { rerender } = render() + + // Simulate the beforeinstallprompt event + const eventHandler = vi.mocked(window.addEventListener).mock.calls + .find(call => call[0] === 'beforeinstallprompt')?.[1] as EventListener + + if (eventHandler) { + eventHandler(mockBeforeInstallPromptEvent) + } + + rerender() + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('calls preventDefault on beforeinstallprompt event', () => { + render() + + const eventHandler = vi.mocked(window.addEventListener).mock.calls + .find(call => call[0] === 'beforeinstallprompt')?.[1] as EventListener + + if (eventHandler) { + eventHandler(mockBeforeInstallPromptEvent) + } + + expect(mockBeforeInstallPromptEvent.preventDefault).toHaveBeenCalled() + }) + }) + + describe('iOS Instructions', () => { + it('shows iOS installation instructions when clicked on iOS', async () => { + mockUserAgentAs('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)') + process.env.NODE_ENV = 'development' + + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + await waitFor(() => { + expect(screen.getByText(/To install: tap the Share button/)).toBeInTheDocument() + }) + }) + + it('closes iOS instructions when close button is clicked', async () => { + mockUserAgentAs('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)') + process.env.NODE_ENV = 'development' + + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + await waitFor(() => { + expect(screen.getByText(/To install: tap the Share button/)).toBeInTheDocument() + }) + + // The close button doesn't have accessible text, so we'll find it by test-id + const closeButton = screen.getByTestId('CloseIcon').closest('button') + fireEvent.click(closeButton!) + + await waitFor(() => { + expect(screen.queryByText(/To install: tap the Share button/)).not.toBeInTheDocument() + }) + }) + }) + + describe('Desktop Install Prompt', () => { + it('triggers install prompt when button is clicked on desktop', async () => { + mockUserAgentAs('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36') + process.env.NODE_ENV = 'development' + + const mockPrompt = vi.fn().mockResolvedValue(undefined) + const mockUserChoice = Promise.resolve({ outcome: 'accepted' }) + + const mockEvent = { + preventDefault: vi.fn(), + prompt: mockPrompt, + userChoice: mockUserChoice + } + + const { rerender } = render() + + // Simulate beforeinstallprompt event after component is mounted + const eventHandler = vi.mocked(window.addEventListener).mock.calls + .find(call => call[0] === 'beforeinstallprompt')?.[1] as EventListener + + if (eventHandler) { + eventHandler(mockEvent as any) + } + + // Rerender to reflect state change + rerender() + + const button = screen.getByRole('button') + fireEvent.click(button) + + await waitFor(() => { + expect(mockPrompt).toHaveBeenCalled() + }) + }) + }) + + describe('Event Listeners', () => { + it('adds event listeners on mount', () => { + render() + + expect(window.addEventListener).toHaveBeenCalledWith('beforeinstallprompt', expect.any(Function)) + expect(window.addEventListener).toHaveBeenCalledWith('appinstalled', expect.any(Function)) + }) + + it('removes event listeners on unmount', () => { + const { unmount } = render() + + unmount() + + expect(window.removeEventListener).toHaveBeenCalledWith('beforeinstallprompt', expect.any(Function)) + expect(window.removeEventListener).toHaveBeenCalledWith('appinstalled', expect.any(Function)) + }) + }) + + describe('Button Styling', () => { + it('applies correct styling to the button', () => { + process.env.NODE_ENV = 'development' + render() + + const button = screen.getByRole('button') + expect(button).toHaveClass('MuiButton-outlined') + expect(button).toHaveStyle({ textTransform: 'none' }) + }) + + it('centers the button in its container', () => { + process.env.NODE_ENV = 'development' + render() + + const button = screen.getByRole('button') + const container = button.closest('div') + + expect(container).toHaveStyle({ + display: 'flex', + justifyContent: 'center' + }) + }) + }) + + describe('Accessibility', () => { + it('has accessible button text', () => { + process.env.NODE_ENV = 'development' + render() + + const button = screen.getByRole('button') + expect(button).toHaveAccessibleName(/Add to Desktop/) + }) + + it('has accessible close button in iOS instructions', async () => { + mockUserAgentAs('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)') + process.env.NODE_ENV = 'development' + + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + + await waitFor(() => { + const closeIcon = screen.getByTestId('CloseIcon') + const closeButton = closeIcon.closest('button') + expect(closeButton).toBeInTheDocument() + expect(closeButton).toHaveAttribute('type', 'button') + }) + }) + }) +})