Skip to content

Commit 9b93d87

Browse files
tyler-daneclaude
authored andcommitted
Feat(web): add simple mobile onboarding (#1096)
* refactor(web): simplify Welcome component by removing unused styled components * feat(web): add mobile onboarding flow with responsive hooks and components * feat(web): add mobile waitlist check component and tests * feat(web): enhance mobile waitlist check with email validation and toast notifications * feat(web): centralize waitlist URL in constants for consistent usage * feat(web): implement mobile waitlist check component with enhanced email validation and user feedback * feat: udpate warning msg * refactor(web): remove mobile route from constants and router * fix(web): show BYPASS WAITLIST button on error for retry Set nextAction to "NOTHING" instead of "NEXT_BTN" when waitlist API errors occur. This shows the BYPASS WAITLIST button which allows users to retry the request, instead of showing a disabled Continue button. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(web): remove email validation from Continue button disabled state Remove email validation check from Continue button when nextAction is NEXT_BTN. This prevents UX dead-ends where users can't retry after errors. The button is now only disabled during loading state. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(web): call onNext only for new users before navigation Move onNext() call to be conditional on new users only, preventing it from being called after navigate() for existing users. This avoids React warnings about state updates on unmounted components. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor(web): extract duplicate media query in useIsMobile Extract the media query string to a constant and create the MediaQueryList object once, eliminating duplication between the initial check and the event listener setup. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 05ade2c commit 9b93d87

17 files changed

Lines changed: 1459 additions & 19 deletions

packages/web/src/common/constants/web.constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export const ID_SOMEDAY_EVENT_ACTION_MENU = "someday-event-action-menu";
2121
export const DATA_EVENT_ELEMENT_ID = "data-event-id";
2222
export const ID_CONTEXT_MENU_ITEMS = "context-menu-items";
2323

24+
export const WAITLIST_URL = "https://www.compasscalendar.com/waitlist";
25+
2426
export enum ZIndex {
2527
LAYER_1 = 1,
2628
LAYER_2 = 2,
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { useIsMobile } from "./useIsMobile";
3+
4+
// Mock window.matchMedia
5+
const mockMatchMedia = jest.fn();
6+
Object.defineProperty(window, "matchMedia", {
7+
writable: true,
8+
value: mockMatchMedia,
9+
});
10+
11+
describe("useIsMobile", () => {
12+
beforeEach(() => {
13+
mockMatchMedia.mockClear();
14+
});
15+
16+
it("returns true for mobile viewport widths", () => {
17+
const mockMediaQuery = {
18+
matches: true,
19+
addEventListener: jest.fn(),
20+
removeEventListener: jest.fn(),
21+
};
22+
mockMatchMedia.mockReturnValue(mockMediaQuery);
23+
24+
const { result } = renderHook(() => useIsMobile());
25+
26+
expect(result.current).toBe(true);
27+
expect(mockMatchMedia).toHaveBeenCalledWith("(max-width: 768px)");
28+
});
29+
30+
it("returns false for desktop viewport widths", () => {
31+
const mockMediaQuery = {
32+
matches: false,
33+
addEventListener: jest.fn(),
34+
removeEventListener: jest.fn(),
35+
};
36+
mockMatchMedia.mockReturnValue(mockMediaQuery);
37+
38+
const { result } = renderHook(() => useIsMobile());
39+
40+
expect(result.current).toBe(false);
41+
expect(mockMatchMedia).toHaveBeenCalledWith("(max-width: 768px)");
42+
});
43+
44+
it("responds to viewport changes", () => {
45+
let changeHandler: (() => void) | null = null;
46+
const mockMediaQuery = {
47+
matches: false,
48+
addEventListener: jest.fn((event, handler) => {
49+
if (event === "change") {
50+
changeHandler = handler;
51+
}
52+
}),
53+
removeEventListener: jest.fn(),
54+
};
55+
mockMatchMedia.mockReturnValue(mockMediaQuery);
56+
57+
const { result, rerender } = renderHook(() => useIsMobile());
58+
59+
expect(result.current).toBe(false);
60+
61+
// Simulate viewport change to mobile
62+
mockMediaQuery.matches = true;
63+
if (changeHandler) {
64+
changeHandler();
65+
}
66+
rerender();
67+
68+
expect(result.current).toBe(true);
69+
70+
// Simulate viewport change back to desktop
71+
mockMediaQuery.matches = false;
72+
if (changeHandler) {
73+
changeHandler();
74+
}
75+
rerender();
76+
77+
expect(result.current).toBe(false);
78+
});
79+
80+
it("cleans up event listener on unmount", () => {
81+
const mockRemoveEventListener = jest.fn();
82+
const mockMediaQuery = {
83+
matches: false,
84+
addEventListener: jest.fn(),
85+
removeEventListener: mockRemoveEventListener,
86+
};
87+
mockMatchMedia.mockReturnValue(mockMediaQuery);
88+
89+
const { unmount } = renderHook(() => useIsMobile());
90+
91+
unmount();
92+
93+
expect(mockRemoveEventListener).toHaveBeenCalledWith(
94+
"change",
95+
expect.any(Function),
96+
);
97+
});
98+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useEffect, useState } from "react";
2+
3+
const MOBILE_BREAKPOINT = "(max-width: 768px)";
4+
5+
/**
6+
* Hook to detect if the current viewport is mobile-sized
7+
* Uses window.matchMedia with a 768px breakpoint
8+
* @returns boolean indicating if viewport is mobile-sized
9+
*/
10+
export const useIsMobile = (): boolean => {
11+
const [isMobile, setIsMobile] = useState<boolean>(false);
12+
13+
useEffect(() => {
14+
// Create media query object once
15+
const mediaQuery = window.matchMedia(MOBILE_BREAKPOINT);
16+
17+
// Check initial state
18+
setIsMobile(mediaQuery.matches);
19+
20+
// Create listener
21+
const handleChange = () => {
22+
setIsMobile(mediaQuery.matches);
23+
};
24+
25+
// Add listener
26+
mediaQuery.addEventListener("change", handleChange);
27+
28+
// Cleanup
29+
return () => {
30+
mediaQuery.removeEventListener("change", handleChange);
31+
};
32+
}, []);
33+
34+
return isMobile;
35+
};

packages/web/src/views/Login/Login.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useAuthCheck } from "@web/auth/useAuthCheck";
44
import { AuthApi } from "@web/common/apis/auth.api";
55
import { WaitlistApi } from "@web/common/apis/waitlist.api";
66
import { ROOT_ROUTES } from "@web/common/constants/routes";
7+
import { WAITLIST_URL } from "@web/common/constants/web.constants";
78
import { AlignItems, FlexDirections } from "@web/components/Flex/styled";
89
import { LoginAbsoluteOverflowLoader } from "@web/components/LoginAbsoluteOverflowLoader/LoginAbsoluteOverflowLoader";
910
import { GoogleButton } from "@web/components/oauth/google/GoogleButton";
@@ -180,7 +181,7 @@ export const LoginView = () => {
180181
when a spot opens up!
181182
</InfoText>
182183
<TertiaryButton
183-
href="https://www.compasscalendar.com/waitlist"
184+
href={WAITLIST_URL}
184185
target="_blank"
185186
rel="noreferrer"
186187
>

packages/web/src/views/Onboarding/OnboardingFlow.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useEffect, useState } from "react";
22
import { useNavigate } from "react-router-dom";
33
import { IS_DEV } from "@web/common/constants/env.constants";
4+
import { useIsMobile } from "@web/common/hooks/useIsMobile";
45
import {
56
useOnboarding,
67
withProvider,
@@ -24,12 +25,16 @@ import {
2425
import { MigrationIntro } from "./steps/events/MigrationIntro/MigrationIntro";
2526
import { MigrationSandbox } from "./steps/events/MigrationSandbox/MigrationSandbox";
2627
import { SomedaySandbox } from "./steps/events/SomedaySandbox/SomedaySandbox";
28+
import { MobileSignIn } from "./steps/mobile/MobileSignIn";
29+
import { MobileWaitlistCheck } from "./steps/mobile/MobileWaitlistCheck/MobileWaitlistCheck";
30+
import { MobileWarning } from "./steps/mobile/MobileWarning";
2731
import { ReminderIntroOne } from "./steps/reminder/ReminderIntroOne";
2832
import { ReminderIntroTwo } from "./steps/reminder/ReminderIntroTwo";
2933

3034
const _OnboardingFlow: React.FC = () => {
3135
const navigate = useNavigate();
3236
const { setHideSteps } = useOnboarding();
37+
const isMobile = useIsMobile();
3338

3439
const [showOnboarding, setShowOnboarding] = useState(false);
3540

@@ -50,6 +55,26 @@ const _OnboardingFlow: React.FC = () => {
5055
});
5156
}
5257

58+
// Mobile-specific login steps
59+
const mobileLoginSteps: OnboardingStepType[] = [
60+
{
61+
id: "mobile-waitlist-check",
62+
component: (props: OnboardingStepProps) => (
63+
<MobileWaitlistCheck {...props} />
64+
),
65+
handlesKeyboardEvents: true,
66+
},
67+
{
68+
id: "mobile-warning",
69+
component: (props: OnboardingStepProps) => <MobileWarning {...props} />,
70+
},
71+
{
72+
id: "mobile-sign-in",
73+
component: (props: OnboardingStepProps) => <MobileSignIn {...props} />,
74+
handlesKeyboardEvents: true,
75+
},
76+
];
77+
5378
const onboardingSteps: OnboardingStepType[] = [
5479
{
5580
id: "welcome-screen",
@@ -144,6 +169,19 @@ const _OnboardingFlow: React.FC = () => {
144169
setHideSteps(true);
145170
}, [setHideSteps]);
146171

172+
// Show mobile flow if on mobile device
173+
if (isMobile) {
174+
return (
175+
<Onboarding
176+
key="mobile-onboarding"
177+
steps={mobileLoginSteps}
178+
onComplete={() => {
179+
navigate("/");
180+
}}
181+
/>
182+
);
183+
}
184+
147185
if (!showOnboarding) {
148186
return (
149187
<Onboarding

packages/web/src/views/Onboarding/steps/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,6 @@ export * from "./events/SomdayIntro/SomedayIntroOne";
1212
export * from "./events/SomdayIntro/SomedayIntroTwo";
1313
export * from "./outro/OutroTwo";
1414
export * from "./outro/OutroQuote";
15+
export * from "./mobile/MobileWarning";
16+
export * from "./mobile/MobileSignIn";
17+
export * from "./mobile/MobileWaitlistCheck/MobileWaitlistCheck";

0 commit comments

Comments
 (0)