diff --git a/apps/web/modules/shell/user-dropdown/DownloadIcons.tsx b/apps/web/modules/shell/user-dropdown/DownloadIcons.tsx new file mode 100644 index 00000000000000..0a96e9c565af27 --- /dev/null +++ b/apps/web/modules/shell/user-dropdown/DownloadIcons.tsx @@ -0,0 +1,625 @@ +export function AppleIcon({ className }: { className?: string }) { + return ( + + ); +} + +export function PlayStoreIcon({ className }: { className?: string }) { + return ( + + ); +} + +export function ChromeIcon({ className }: { className?: string }) { + return ( + + ); +} + +export function SafariIcon({ className }: { className?: string }) { + return ( + + ); +} + +export function FirefoxIcon({ className }: { className?: string }) { + return ( + + ); +} + +export function EdgeIcon({ className }: { className?: string }) { + return ( + + ); +} + +export function WindowsIcon({ className }: { className?: string }) { + return ( + + ); +} + +export function LinuxIcon({ className }: { className?: string }) { + return ( + + ); +} diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx index 91bef2c8bbbaeb..241a02c1bd4885 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx @@ -16,6 +16,14 @@ vi.mock("@calcom/lib/hooks/useLocale", () => ({ }), })); +vi.mock("@calcom/lib/hooks/useUserAgentData", () => ({ + useUserAgentData: () => ({ + os: "linux", + browser: "chrome", + isMobile: false, + }), +})); + const mockUseMeQuery = vi.fn(); vi.mock("@calcom/trpc/react/hooks/useMeQuery", () => ({ default: () => mockUseMeQuery(), @@ -39,14 +47,20 @@ vi.mock("@calcom/ui/components/icon", () => ({ Icon: ({ name }: { name: string }) => {name}, })); -vi.mock("@calcom/ui/components/dropdown", () => ({ - Dropdown: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownItem: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownMenuItem: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownMenuPortal: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownMenuSeparator: () =>
, - DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, +vi.mock("@coss/ui/components/menu", () => ({ + Menu: ({ children }: { children: React.ReactNode }) =>
{children}
, + MenuTrigger: ({ children, render }: { children: React.ReactNode; render?: React.ReactElement }) => { + if (render) { + return React.cloneElement(render, {}, children); + } + return
{children}
; + }, + MenuPopup: ({ children }: { children: React.ReactNode }) =>
{children}
, + MenuItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + MenuSeparator: () =>
, + MenuSub: ({ children }: { children: React.ReactNode }) =>
{children}
, + MenuSubTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + MenuSubPopup: ({ children }: { children: React.ReactNode }) =>
{children}
, })); vi.mock("@calcom/ui/classNames", () => ({ @@ -232,7 +246,7 @@ describe("UserDropdown", () => { const { UserDropdown } = await import("./UserDropdown"); const { getByTestId } = render(); - expect(getByTestId("dropdown")).toBeInTheDocument(); + expect(getByTestId("menu")).toBeInTheDocument(); }); it("should render dropdown when isPending is true (loading state)", async () => { @@ -244,7 +258,7 @@ describe("UserDropdown", () => { const { UserDropdown } = await import("./UserDropdown"); const { getByTestId } = render(); - expect(getByTestId("dropdown")).toBeInTheDocument(); + expect(getByTestId("menu")).toBeInTheDocument(); }); }); }); diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index 65f509db6a61e9..30be66522bae61 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -1,26 +1,38 @@ -import { signOut } from "next-auth/react"; -import { usePathname } from "next/navigation"; -import type { MouseEvent } from "react"; -import { useEffect, useState } from "react"; - -import { ROADMAP, DESKTOP_APP_LINK } from "@calcom/lib/constants"; +import { ROADMAP } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useUserAgentData } from "@calcom/lib/hooks/useUserAgentData"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import classNames from "@calcom/ui/classNames"; import { Avatar } from "@calcom/ui/components/avatar"; -import { - Dropdown, - DropdownItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuPortal, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@calcom/ui/components/dropdown"; import { Icon } from "@calcom/ui/components/icon"; -// TODO (Platform): we shouldnt be importing from web here import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes"; import FreshChatProvider from "@calcom/web/modules/ee/support/lib/freshchat/FreshChatProvider"; +import { + Menu, + MenuItem, + MenuPopup, + MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuTrigger, +} from "@coss/ui/components/menu"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { signOut } from "next-auth/react"; +import type { MouseEvent } from "react"; +import { useEffect, useState } from "react"; + +import { + AppleIcon, + ChromeIcon, + EdgeIcon, + FirefoxIcon, + LinuxIcon, + PlayStoreIcon, + SafariIcon, + WindowsIcon, +} from "./DownloadIcons"; declare global { interface Window { @@ -32,7 +44,6 @@ declare global { } } -// NOTE: This interface only includes types for commands we currently use. type BeaconFunction = { (command: "session-data", data: Record): void; // Catch-all for other methods - add explicit types above if using new commands @@ -43,12 +54,25 @@ interface UserDropdownProps { small?: boolean; } +const DOWNLOAD_LINKS = { + ios: "https://go.cal.com/iOS", + android: "https://go.cal.com/android", + chrome: "https://go.cal.com/chrome", + safari: "https://go.cal.com/safari", + firefox: "https://go.cal.com/firefox", + edge: "https://go.cal.com/edge", + macos: "https://cal.com/download", + windows: "https://cal.com/download", + linux: "https://cal.com/download", +} as const; + export function UserDropdown({ small }: UserDropdownProps) { const { isPlatformUser } = useGetUserAttributes(); const { t } = useLocale(); const { data: user, isPending } = useMeQuery(); const pathname = usePathname(); const isPlatformPages = pathname?.startsWith("/settings/platform"); + const { os, browser, isMobile } = useUserAgentData(); useEffect(() => { if (typeof window === "undefined") return; @@ -105,142 +129,173 @@ export function UserDropdown({ small }: UserDropdownProps) { } return ( - - setMenuOpen((menuOpen) => !menuOpen)} disabled={isPending}> - - - - - - { - setMenuOpen(false); - }} - className="group overflow-hidden rounded-md"> + - - - + )} + + }> + + {t("visit_roadmap")} + + + + {t("help")} + + {!isPlatformPages && isMobile && os === "ios" && ( + }> + + {t("download_app")} + + )} + {!isPlatformPages && isMobile && os === "android" && ( + }> + + {t("download_app")} + + )} + {!isPlatformPages && !isMobile && ( + + + + {t("download_app")} + + + {os === "macos" && ( + }> + + {t("download_for_macos")} + + )} + {os === "windows" && ( + }> + + {t("download_for_windows")} + + )} + {os === "linux" && ( + }> + + {t("download_for_linux")} + + )} + {browser === "chrome" && ( + }> + + {t("download_chrome_extension")} + + )} + {browser === "safari" && ( + }> + + {t("download_safari_extension")} + + )} + {browser === "firefox" && ( + }> + + {t("download_firefox_extension")} + + )} + {browser === "edge" && ( + }> + + {t("download_edge_extension")} + + )} + }> + + {t("download_for_ios")} + + }> + + {t("download_for_android")} + + + + )} + + {!isPlatformPages && isPlatformUser && ( + } + className="todesktop:hidden hidden lg:flex"> + + {t("platform")} + + )} + + + { + signOut({ callbackUrl: "/auth/logout" }); + }}> + + {t("sign_out")} + + + + ); } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4fc1e75ac8597e..57d44d3463d475 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1785,6 +1785,16 @@ "using_booking_questions_as_variables": "How do I use booking questions as variables?", "using_form_responses_as_variables": "How do I use form responses as variables?", "download_desktop_app": "Download desktop app", + "download_app": "Download app", + "download_for_ios": "iOS", + "download_for_android": "Android", + "download_chrome_extension": "Chrome Extension", + "download_safari_extension": "Safari Extension", + "download_firefox_extension": "Firefox Extension", + "download_edge_extension": "Edge Extension", + "download_for_macos": "MacOS", + "download_for_windows": "Windows", + "download_for_linux": "Linux", "set_ping_link": "Set Ping link", "rate_limit_exceeded": "Rate limit exceeded", "when_something_happens": "When something happens", diff --git a/packages/lib/hooks/useUserAgentData.test.ts b/packages/lib/hooks/useUserAgentData.test.ts new file mode 100644 index 00000000000000..a7d62f4876b8ab --- /dev/null +++ b/packages/lib/hooks/useUserAgentData.test.ts @@ -0,0 +1,174 @@ +import { renderHook } from "@testing-library/react-hooks"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useUserAgentData } from "./useUserAgentData"; + +describe("useUserAgentData hook", () => { + const originalNavigator = global.navigator; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(global, "navigator", { + value: originalNavigator, + writable: true, + }); + }); + + function mockUserAgent(userAgent: string) { + Object.defineProperty(global, "navigator", { + value: { userAgent }, + writable: true, + }); + } + + describe("OS detection", () => { + it("should detect iOS on iPhone", () => { + mockUserAgent( + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1" + ); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.os).toBe("ios"); + expect(result.current.isMobile).toBe(true); + }); + + it("should detect iOS on iPad", () => { + mockUserAgent( + "Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1" + ); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.os).toBe("ios"); + expect(result.current.isMobile).toBe(true); + }); + + it("should detect Android", () => { + mockUserAgent( + "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36" + ); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.os).toBe("android"); + expect(result.current.isMobile).toBe(true); + }); + + it("should detect macOS", () => { + mockUserAgent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" + ); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.os).toBe("macos"); + expect(result.current.isMobile).toBe(false); + }); + + it("should detect Windows", () => { + mockUserAgent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" + ); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.os).toBe("windows"); + expect(result.current.isMobile).toBe(false); + }); + + it("should detect Linux", () => { + mockUserAgent( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" + ); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.os).toBe("linux"); + expect(result.current.isMobile).toBe(false); + }); + + it("should return unknown for unrecognized OS", () => { + mockUserAgent("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.os).toBe("unknown"); + expect(result.current.isMobile).toBe(false); + }); + }); + + describe("Browser detection", () => { + it("should detect Chrome on desktop", () => { + mockUserAgent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" + ); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.browser).toBe("chrome"); + }); + + it("should detect Chrome on iOS (CriOS)", () => { + mockUserAgent( + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/116.0.5845.103 Mobile/15E148 Safari/604.1" + ); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.browser).toBe("chrome"); + }); + + it("should detect Safari on macOS", () => { + mockUserAgent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15" + ); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.browser).toBe("safari"); + }); + + it("should detect Safari on iOS", () => { + mockUserAgent( + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1" + ); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.browser).toBe("safari"); + }); + + it("should detect Firefox on desktop", () => { + mockUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/116.0"); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.browser).toBe("firefox"); + }); + + it("should detect Firefox on iOS (FxiOS)", () => { + mockUserAgent( + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/116.0 Mobile/15E148 Safari/605.1.15" + ); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.browser).toBe("firefox"); + }); + + it("should detect Edge", () => { + mockUserAgent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.69" + ); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.browser).toBe("edge"); + }); + + it("should not detect Chrome when Edge is present", () => { + mockUserAgent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.69" + ); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.browser).toBe("edge"); + expect(result.current.browser).not.toBe("chrome"); + }); + + it("should return unknown for unrecognized browser", () => { + mockUserAgent("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.browser).toBe("unknown"); + }); + }); + + describe("SSR handling", () => { + it("should return defaults when navigator is undefined", () => { + Object.defineProperty(global, "navigator", { + value: undefined, + writable: true, + }); + const { result } = renderHook(() => useUserAgentData()); + expect(result.current.os).toBe("unknown"); + expect(result.current.browser).toBe("unknown"); + expect(result.current.isMobile).toBe(false); + }); + }); +}); diff --git a/packages/lib/hooks/useUserAgentData.ts b/packages/lib/hooks/useUserAgentData.ts new file mode 100644 index 00000000000000..54abfb0969bbc7 --- /dev/null +++ b/packages/lib/hooks/useUserAgentData.ts @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; + +export type OperatingSystem = "macos" | "windows" | "linux" | "ios" | "android" | "unknown"; +export type Browser = "chrome" | "safari" | "firefox" | "edge" | "unknown"; + +export interface UserAgentData { + os: OperatingSystem; + browser: Browser; + isMobile: boolean; +} + +function detectOS(userAgent: string): OperatingSystem { + const ua = userAgent.toLowerCase(); + + if (/iphone|ipad|ipod/.test(ua)) { + return "ios"; + } + if (/android/.test(ua)) { + return "android"; + } + if (/macintosh|mac os x/.test(ua)) { + return "macos"; + } + if (/windows/.test(ua)) { + return "windows"; + } + if (/linux/.test(ua)) { + return "linux"; + } + return "unknown"; +} + +function detectBrowser(userAgent: string): Browser { + const ua = userAgent.toLowerCase(); + + if (/edg\//.test(ua)) { + return "edge"; + } + if (/chrome|chromium|crios/.test(ua) && !/edg\//.test(ua)) { + return "chrome"; + } + if (/firefox|fxios/.test(ua)) { + return "firefox"; + } + if (/safari/.test(ua) && !/chrome|chromium|crios/.test(ua)) { + return "safari"; + } + return "unknown"; +} + +export function useUserAgentData(): UserAgentData { + const [userAgentData, setUserAgentData] = useState({ + os: "unknown", + browser: "unknown", + isMobile: false, + }); + + useEffect(() => { + if (typeof window === "undefined" || typeof navigator === "undefined") { + return; + } + + const userAgent = navigator.userAgent; + const os = detectOS(userAgent); + const browser = detectBrowser(userAgent); + const isMobile = os === "ios" || os === "android"; + + setUserAgentData({ os, browser, isMobile }); + }, []); + + return userAgentData; +} diff --git a/packages/ui/components/dropdown/Dropdown.tsx b/packages/ui/components/dropdown/Dropdown.tsx index 599505e2ea1bab..28b242da95a95b 100644 --- a/packages/ui/components/dropdown/Dropdown.tsx +++ b/packages/ui/components/dropdown/Dropdown.tsx @@ -1,13 +1,11 @@ +import classNames from "@calcom/ui/classNames"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import Link from "next/link"; import type { ComponentProps } from "react"; import { forwardRef } from "react"; - -import classNames from "@calcom/ui/classNames"; - import type { ButtonColor } from "../button"; -import { Icon } from "../icon"; import type { IconName } from "../icon"; +import { Icon } from "../icon"; export const Dropdown = DropdownMenuPrimitive.Root; @@ -112,6 +110,45 @@ DropdownMenuCheckboxItem.displayName = "DropdownMenuCheckboxItem"; export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; +export const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +type DropdownMenuSubTriggerProps = ComponentProps<(typeof DropdownMenuPrimitive)["SubTrigger"]>; +export const DropdownMenuSubTrigger = forwardRef( + ({ className = "", children, ...props }, forwardedRef) => ( + + {children} + + + ) +); +DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger"; + +type DropdownMenuSubContentProps = ComponentProps<(typeof DropdownMenuPrimitive)["SubContent"]>; +export const DropdownMenuSubContent = forwardRef( + ({ children, sideOffset = 2, alignOffset = -5, ...props }, forwardedRef) => { + return ( + + {children} + + ); + } +); +DropdownMenuSubContent.displayName = "DropdownMenuSubContent"; + type DropdownMenuRadioItemProps = ComponentProps<(typeof DropdownMenuPrimitive)["RadioItem"]>; export const DropdownMenuRadioItem = forwardRef( ({ children, ...props }, forwardedRef) => { diff --git a/packages/ui/components/dropdown/index.ts b/packages/ui/components/dropdown/index.ts index f7dd2fec044c05..b9522277d37f4f 100644 --- a/packages/ui/components/dropdown/index.ts +++ b/packages/ui/components/dropdown/index.ts @@ -1,6 +1,6 @@ export { - Dropdown, ButtonOrLink, + Dropdown, DropdownItem, DropdownMenuCheckboxItem, DropdownMenuContent, @@ -8,5 +8,8 @@ export { DropdownMenuLabel, DropdownMenuPortal, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "./Dropdown";