diff --git a/e2e/oauth/oauth-overlay.spec.ts b/e2e/oauth/oauth-overlay.spec.ts index c7ba4b635..569719d98 100644 --- a/e2e/oauth/oauth-overlay.spec.ts +++ b/e2e/oauth/oauth-overlay.spec.ts @@ -22,8 +22,17 @@ import { * The overlay shows two phases: * 1. OAuth phase: "Complete Google sign-in..." - when isSyncing=true, importing=false * 2. Import phase: "Importing your Google Calendar..." - when importing=true + * + * NOTE: These tests are skipped on mobile because the MobileGate component + * blocks the entire app on mobile viewports, preventing the OAuth overlay + * from ever being rendered. This is intentional product behavior. */ test.describe("OAuth Overlay", () => { + // Skip on mobile - MobileGate blocks the app, so OAuth overlay never renders + test.skip( + ({ isMobile }) => isMobile, + "OAuth overlay not available on mobile", + ); test.beforeEach(async ({ page }) => { await prepareOAuthTestPage(page); await page.goto("/"); @@ -89,25 +98,11 @@ test.describe("OAuth Overlay", () => { await expectBodyLocked(page, false); }); - test("blurs active element when overlay activates", async ({ - page, - isMobile, - }) => { - // On mobile, the main grid might not be visible - use a different focusable element - // or skip for mobile as the blur behavior is the same regardless of viewport - if (isMobile) { - // On mobile, find any focusable element that's visible - const focusable = page - .locator("button:visible, [tabindex]:visible") - .first(); - await focusable.waitFor({ state: "visible", timeout: 10000 }); - await focusable.focus(); - } else { - // Wait for main grid to be visible and focusable on desktop - const mainGrid = page.locator("#mainGrid"); - await mainGrid.waitFor({ state: "visible", timeout: 10000 }); - await mainGrid.focus(); - } + test("blurs active element when overlay activates", async ({ page }) => { + // Wait for main grid to be visible and focusable + const mainGrid = page.locator("#mainGrid"); + await mainGrid.waitFor({ state: "visible", timeout: 10000 }); + await mainGrid.focus(); await page.waitForTimeout(100); // Give time for focus to settle @@ -201,6 +196,12 @@ test.describe("OAuth Overlay", () => { }); test.describe("OAuth Overlay - Edge Cases", () => { + // Skip on mobile - MobileGate blocks the app, so OAuth overlay never renders + test.skip( + ({ isMobile }) => isMobile, + "OAuth overlay not available on mobile", + ); + test.beforeEach(async ({ page }) => { await prepareOAuthTestPage(page); await page.goto("/"); diff --git a/packages/web/src/common/hooks/useIsMobile.ts b/packages/web/src/common/hooks/useIsMobile.ts index 28962119d..d77c6ded9 100644 --- a/packages/web/src/common/hooks/useIsMobile.ts +++ b/packages/web/src/common/hooks/useIsMobile.ts @@ -2,19 +2,31 @@ import { useEffect, useState } from "react"; const MOBILE_BREAKPOINT = "(max-width: 768px)"; +/** + * Get initial mobile state synchronously + * This prevents unnecessary API calls on mobile devices during first render + */ +const getInitialMobileState = (): boolean => { + if (typeof window === "undefined") { + return false; + } + return window.matchMedia(MOBILE_BREAKPOINT).matches; +}; + /** * Hook to detect if the current viewport is mobile-sized * Uses window.matchMedia with a 768px breakpoint * @returns boolean indicating if viewport is mobile-sized */ export const useIsMobile = (): boolean => { - const [isMobile, setIsMobile] = useState(false); + // Initialize synchronously to prevent unnecessary provider mounts on mobile + const [isMobile, setIsMobile] = useState(getInitialMobileState); useEffect(() => { // Create media query object once const mediaQuery = window.matchMedia(MOBILE_BREAKPOINT); - // Check initial state + // Update state if it changed (shouldn't happen on first render, but ensures consistency) setIsMobile(mediaQuery.matches); // Create listener diff --git a/packages/web/src/components/MobileGate/MobileGate.test.tsx b/packages/web/src/components/MobileGate/MobileGate.test.tsx new file mode 100644 index 000000000..89e11372f --- /dev/null +++ b/packages/web/src/components/MobileGate/MobileGate.test.tsx @@ -0,0 +1,80 @@ +import "@testing-library/jest-dom"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { render } from "@web/__tests__/__mocks__/mock.render"; +import { MobileGate } from "./MobileGate"; + +describe("MobileGate", () => { + const mockWindowOpen = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + window.open = mockWindowOpen; + }); + + describe("Component Rendering", () => { + it("renders the title message", () => { + render(); + + expect( + screen.getByText("Compass isn't built for mobile yet"), + ).toBeInTheDocument(); + }); + + it("renders the descriptive message", () => { + render(); + + expect( + screen.getByText( + /We're focusing on perfecting the web experience first/, + ), + ).toBeInTheDocument(); + }); + + it("renders the Join Mobile Waitlist button", () => { + render(); + + const waitlistButton = screen.getByRole("button", { + name: /join mobile waitlist/i, + }); + expect(waitlistButton).toBeInTheDocument(); + }); + }); + + describe("Waitlist Button Behavior", () => { + it("opens waitlist URL in new tab when clicked", async () => { + const user = userEvent.setup(); + render(); + + const waitlistButton = screen.getByRole("button", { + name: /join mobile waitlist/i, + }); + await user.click(waitlistButton); + + expect(mockWindowOpen).toHaveBeenCalledTimes(1); + expect(mockWindowOpen).toHaveBeenCalledWith( + "https://tylerdane.kit.com/compass-mobile", + "_blank", + "noopener,noreferrer", + ); + }); + }); + + describe("Accessibility", () => { + it("has proper button role", () => { + render(); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent("Join Mobile Waitlist"); + }); + + it("renders heading for the title", () => { + render(); + + const heading = screen.getByRole("heading", { level: 1 }); + expect(heading).toBeInTheDocument(); + expect(heading).toHaveTextContent("Compass isn't built for mobile yet"); + }); + }); +}); diff --git a/packages/web/src/components/MobileGate/MobileGate.tsx b/packages/web/src/components/MobileGate/MobileGate.tsx new file mode 100644 index 000000000..bc1c48e9f --- /dev/null +++ b/packages/web/src/components/MobileGate/MobileGate.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +const WAITLIST_URL = "https://tylerdane.kit.com/compass-mobile"; + +export const MobileGate: React.FC = () => { + const handleJoinWaitlist = () => { + window.open(WAITLIST_URL, "_blank", "noopener,noreferrer"); + }; + + return ( +
+
+

+ Compass isn't built for mobile yet +

+

+ We're focusing on perfecting the web experience first. Join our + mobile waitlist to be the first to know when we launch. +

+ +
+
+ ); +}; diff --git a/packages/web/src/views/Root.tsx b/packages/web/src/views/Root.tsx index 2a3a949da..0c0659c55 100644 --- a/packages/web/src/views/Root.tsx +++ b/packages/web/src/views/Root.tsx @@ -1,8 +1,16 @@ import { UserProvider } from "@web/auth/context/UserProvider"; +import { useIsMobile } from "@web/common/hooks/useIsMobile"; import { AuthenticatedLayout } from "@web/components/AuthenticatedLayout/AuthenticatedLayout"; +import { MobileGate } from "@web/components/MobileGate/MobileGate"; import SocketProvider from "@web/socket/provider/SocketProvider"; export const RootView = () => { + const isMobile = useIsMobile(); + + if (isMobile) { + return ; + } + return (