Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 20 additions & 19 deletions e2e/oauth/oauth-overlay.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("/");
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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("/");
Expand Down
16 changes: 14 additions & 2 deletions packages/web/src/common/hooks/useIsMobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(false);
// Initialize synchronously to prevent unnecessary provider mounts on mobile
const [isMobile, setIsMobile] = useState<boolean>(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
Expand Down
80 changes: 80 additions & 0 deletions packages/web/src/components/MobileGate/MobileGate.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MobileGate />);

expect(
screen.getByText("Compass isn't built for mobile yet"),
).toBeInTheDocument();
});

it("renders the descriptive message", () => {
render(<MobileGate />);

expect(
screen.getByText(
/We're focusing on perfecting the web experience first/,
),
).toBeInTheDocument();
});

it("renders the Join Mobile Waitlist button", () => {
render(<MobileGate />);

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(<MobileGate />);

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(<MobileGate />);

const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent("Join Mobile Waitlist");
});

it("renders heading for the title", () => {
render(<MobileGate />);

const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toBeInTheDocument();
expect(heading).toHaveTextContent("Compass isn't built for mobile yet");
});
});
});
29 changes: 29 additions & 0 deletions packages/web/src/components/MobileGate/MobileGate.tsx
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot update this component to use tailwind, not styled-components

Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-bg-primary flex min-h-dvh items-center justify-center p-4">
<div className="border-border-primary bg-bg-secondary flex w-[400px] max-w-[90vw] flex-col items-center rounded border p-8 text-center">
<h1 className="mb-6 font-sans text-2xl font-medium text-white">
Compass isn&apos;t built for mobile yet
</h1>
<p className="text-text-light-inactive mb-8 font-sans text-base leading-relaxed">
We&apos;re focusing on perfecting the web experience first. Join our
mobile waitlist to be the first to know when we launch.
</p>
<button
onClick={handleJoinWaitlist}
className="bg-accent-primary focus:outline-accent-primary min-h-[44px] cursor-pointer rounded border-none px-8 py-2 font-sans text-base font-medium text-white transition-opacity duration-300 hover:opacity-90 focus:outline focus:outline-2 focus:outline-offset-2"
>
Join Mobile Waitlist
</button>
</div>
</div>
);
};
8 changes: 8 additions & 0 deletions packages/web/src/views/Root.tsx
Original file line number Diff line number Diff line change
@@ -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 <MobileGate />;
}

return (
<UserProvider>
<SocketProvider>
Expand Down