From ceca99b128bdfd17c8780d210a806eb4981a6883 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:22:45 +0000 Subject: [PATCH 01/21] feat: add multi-step download app dropdown with platform-specific options - Add DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent components to UI library - Create useUserAgentData hook for detecting user's OS and browser - Replace single download link with multi-step dropdown showing: - iOS and Android (always shown) - Browser extensions (Chrome, Safari, Firefox, Edge) based on detected browser - Desktop apps (MacOS, Windows, Linux) based on detected OS - Add i18n translations for all new download options Co-Authored-By: peer@cal.com --- .../shell/user-dropdown/UserDropdown.tsx | 140 ++++++++++++++++-- apps/web/public/static/locales/en/common.json | 10 ++ packages/lib/hooks/useUserAgentData.ts | 72 +++++++++ packages/ui/components/dropdown/Dropdown.tsx | 45 +++++- packages/ui/components/dropdown/index.ts | 5 +- 5 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 packages/lib/hooks/useUserAgentData.ts diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index 65f509db6a61e9..821e008effcbfe 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -1,10 +1,6 @@ -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"; @@ -15,12 +11,19 @@ import { DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, 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 { usePathname } from "next/navigation"; +import { signOut } from "next-auth/react"; +import type { MouseEvent } from "react"; +import { useEffect, useState } from "react"; declare global { interface Window { @@ -43,12 +46,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 } = useUserAgentData(); useEffect(() => { if (typeof window === "undefined") return; @@ -135,7 +151,7 @@ export function UserDropdown({ small }: UserDropdownProps) { - {isPending ? "Loading..." : user?.name ?? "Nameless User"} + {isPending ? "Loading..." : (user?.name ?? "Nameless User")} {!isPlatformPages && ( - - - {t("download_desktop_app")} - - + + + + + {t("download_app")} + + + + + + {t("download_for_ios")} + + + + + {t("download_for_android")} + + + {browser === "chrome" && ( + + + {t("download_chrome_extension")} + + + )} + {browser === "safari" && ( + + + {t("download_safari_extension")} + + + )} + {browser === "firefox" && ( + + + {t("download_firefox_extension")} + + + )} + {browser === "edge" && ( + + + {t("download_edge_extension")} + + + )} + {os === "macos" && ( + + + {t("download_for_macos")} + + + )} + {os === "windows" && ( + + + {t("download_for_windows")} + + + )} + {os === "linux" && ( + + + {t("download_for_linux")} + + + )} + + )} {!isPlatformPages && isPlatformUser && ( diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d169cbef2b4acf..d5208c9665794d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1784,6 +1784,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.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"; From 3bdd7004ddfa35ae9a5b5ff965bf035a6f4baafc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:28:41 +0000 Subject: [PATCH 02/21] fix: use valid icon names (globe, monitor) instead of invalid ones Co-Authored-By: peer@cal.com --- apps/web/modules/shell/user-dropdown/UserDropdown.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index 821e008effcbfe..c0e05a0cae9096 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -253,7 +253,7 @@ export function UserDropdown({ small }: UserDropdownProps) { {browser === "chrome" && ( @@ -264,7 +264,7 @@ export function UserDropdown({ small }: UserDropdownProps) { {browser === "safari" && ( @@ -297,7 +297,7 @@ export function UserDropdown({ small }: UserDropdownProps) { {os === "macos" && ( @@ -308,7 +308,7 @@ export function UserDropdown({ small }: UserDropdownProps) { {os === "windows" && ( @@ -319,7 +319,7 @@ export function UserDropdown({ small }: UserDropdownProps) { {os === "linux" && ( From b4232c5f6083994f0f89f4ffeb259119228c47d5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:32:43 +0000 Subject: [PATCH 03/21] test: add mocks for new dropdown submenu components and useUserAgentData hook Co-Authored-By: peer@cal.com --- .../modules/shell/user-dropdown/UserDropdown.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx index 91bef2c8bbbaeb..bce3b58f9e9cf9 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(), @@ -47,6 +55,9 @@ vi.mock("@calcom/ui/components/dropdown", () => ({ DropdownMenuPortal: ({ children }: { children: React.ReactNode }) =>
{children}
, DropdownMenuSeparator: () =>
, DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSub: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSubTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSubContent: ({ children }: { children: React.ReactNode }) =>
{children}
, })); vi.mock("@calcom/ui/classNames", () => ({ From 45a2b74c2c6e02b1e173fc174bd3bc8b092a3430 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:58:43 +0000 Subject: [PATCH 04/21] refactor: migrate download app submenu to coss.com/ui Menu components Co-Authored-By: peer@cal.com --- .../shell/user-dropdown/UserDropdown.test.tsx | 17 ++- .../shell/user-dropdown/UserDropdown.tsx | 140 +++++++++--------- 2 files changed, 87 insertions(+), 70 deletions(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx index bce3b58f9e9cf9..af223cef10b4f9 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx @@ -55,9 +55,20 @@ vi.mock("@calcom/ui/components/dropdown", () => ({ DropdownMenuPortal: ({ children }: { children: React.ReactNode }) =>
{children}
, DropdownMenuSeparator: () =>
, DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownMenuSub: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownMenuSubTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownMenuSubContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@coss/ui/components/menu", () => ({ + MenuSub: ({ children }: { children: React.ReactNode }) =>
{children}
, + MenuSubTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + MenuSubPopup: ({ children }: { children: React.ReactNode }) =>
{children}
, + MenuItem: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("lucide-react", () => ({ + DownloadIcon: () => download, + SmartphoneIcon: () => smartphone, + GlobeIcon: () => globe, + MonitorIcon: () => monitor, })); vi.mock("@calcom/ui/classNames", () => ({ diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index c0e05a0cae9096..21df84d7176dc2 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -11,12 +11,11 @@ import { DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@calcom/ui/components/dropdown"; import { Icon } from "@calcom/ui/components/icon"; +import { MenuSub, MenuSubTrigger, MenuSubPopup, MenuItem } from "@coss/ui/components/menu"; +import { DownloadIcon, SmartphoneIcon, GlobeIcon, MonitorIcon } from "lucide-react"; // 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"; @@ -224,111 +223,118 @@ export function UserDropdown({ small }: UserDropdownProps) {
{!isPlatformPages && ( - - - - - {t("download_app")} - - - - - + + + {t("download_app")} + + + + + className="flex w-full items-center gap-2"> + {t("download_for_ios")} - - - - + + + + className="flex w-full items-center gap-2"> + {t("download_for_android")} -
-
+ + {browser === "chrome" && ( - - + + className="flex w-full items-center gap-2"> + {t("download_chrome_extension")} - - + + )} {browser === "safari" && ( - - + + className="flex w-full items-center gap-2"> + {t("download_safari_extension")} - - + + )} {browser === "firefox" && ( - - + + className="flex w-full items-center gap-2"> + {t("download_firefox_extension")} - - + + )} {browser === "edge" && ( - - + + className="flex w-full items-center gap-2"> + {t("download_edge_extension")} - - + + )} {os === "macos" && ( - - + + className="flex w-full items-center gap-2"> + {t("download_for_macos")} - - + + )} {os === "windows" && ( - - + + className="flex w-full items-center gap-2"> + {t("download_for_windows")} - - + + )} {os === "linux" && ( - - + + className="flex w-full items-center gap-2"> + {t("download_for_linux")} - - + + )} - - + + )} {!isPlatformPages && isPlatformUser && ( From 36bfadb165b7155bda25df6fbd56668e4e1a5377 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:26:43 +0000 Subject: [PATCH 05/21] fix: revert to Radix-based dropdown submenu components to fix 500 error The coss-ui Menu components (MenuSub, MenuSubTrigger, MenuSubPopup) require a Base UI Menu.Root context, but they were being used inside a Radix UI Dropdown which caused a context mismatch and 500 error. Reverted to using the Radix-based DropdownMenuSub, DropdownMenuSubTrigger, and DropdownMenuSubContent components which work correctly within the existing Radix Dropdown context. Co-Authored-By: peer@cal.com --- .../shell/user-dropdown/UserDropdown.test.tsx | 17 +-- .../shell/user-dropdown/UserDropdown.tsx | 140 +++++++++--------- 2 files changed, 70 insertions(+), 87 deletions(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx index af223cef10b4f9..bce3b58f9e9cf9 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx @@ -55,20 +55,9 @@ vi.mock("@calcom/ui/components/dropdown", () => ({ DropdownMenuPortal: ({ children }: { children: React.ReactNode }) =>
{children}
, DropdownMenuSeparator: () =>
, DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); - -vi.mock("@coss/ui/components/menu", () => ({ - MenuSub: ({ children }: { children: React.ReactNode }) =>
{children}
, - MenuSubTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, - MenuSubPopup: ({ children }: { children: React.ReactNode }) =>
{children}
, - MenuItem: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); - -vi.mock("lucide-react", () => ({ - DownloadIcon: () => download, - SmartphoneIcon: () => smartphone, - GlobeIcon: () => globe, - MonitorIcon: () => monitor, + DropdownMenuSub: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSubTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSubContent: ({ children }: { children: React.ReactNode }) =>
{children}
, })); vi.mock("@calcom/ui/classNames", () => ({ diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index 21df84d7176dc2..c0e05a0cae9096 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -11,11 +11,12 @@ import { DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@calcom/ui/components/dropdown"; import { Icon } from "@calcom/ui/components/icon"; -import { MenuSub, MenuSubTrigger, MenuSubPopup, MenuItem } from "@coss/ui/components/menu"; -import { DownloadIcon, SmartphoneIcon, GlobeIcon, MonitorIcon } from "lucide-react"; // 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"; @@ -223,118 +224,111 @@ export function UserDropdown({ small }: UserDropdownProps) {
{!isPlatformPages && ( - - - - {t("download_app")} - - - - + + + + {t("download_app")} + + + + + - + href={DOWNLOAD_LINKS.ios}> {t("download_for_ios")} - - - - +
+ + - + href={DOWNLOAD_LINKS.android}> {t("download_for_android")} - - +
+
{browser === "chrome" && ( - - + - + href={DOWNLOAD_LINKS.chrome}> {t("download_chrome_extension")} - - + + )} {browser === "safari" && ( - - + - + href={DOWNLOAD_LINKS.safari}> {t("download_safari_extension")} - - + + )} {browser === "firefox" && ( - - + - + href={DOWNLOAD_LINKS.firefox}> {t("download_firefox_extension")} - - + + )} {browser === "edge" && ( - - + - + href={DOWNLOAD_LINKS.edge}> {t("download_edge_extension")} - - + + )} {os === "macos" && ( - - + - + href={DOWNLOAD_LINKS.macos}> {t("download_for_macos")} - - + + )} {os === "windows" && ( - - + - + href={DOWNLOAD_LINKS.windows}> {t("download_for_windows")} - - + + )} {os === "linux" && ( - - + - + href={DOWNLOAD_LINKS.linux}> {t("download_for_linux")} - - + + )} - - + + )} {!isPlatformPages && isPlatformUser && ( From de7aaf722ca74de1e159620982b3e99c1ba847c2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:38:32 +0000 Subject: [PATCH 06/21] feat: use custom SVG icons for download app dropdown options Replace Lucide icons with custom brand SVGs for each download option: - Apple icon for iOS and macOS - Play Store icon for Android - Chrome, Safari, Firefox, Edge icons for browser extensions - Windows and Linux icons for desktop apps Co-Authored-By: peer@cal.com --- .../shell/user-dropdown/UserDropdown.tsx | 36 +- .../web/public/icons/download/apple-white.svg | 3 + apps/web/public/icons/download/apple.svg | 3 + apps/web/public/icons/download/chrome.svg | 21 + apps/web/public/icons/download/edge.svg | 46 ++ apps/web/public/icons/download/firefox.svg | 106 ++++ apps/web/public/icons/download/linux.svg | 563 ++++++++++++++++++ apps/web/public/icons/download/play-store.svg | 65 ++ apps/web/public/icons/download/safari.svg | 158 +++++ apps/web/public/icons/download/windows.svg | 10 + 10 files changed, 1002 insertions(+), 9 deletions(-) create mode 100644 apps/web/public/icons/download/apple-white.svg create mode 100644 apps/web/public/icons/download/apple.svg create mode 100644 apps/web/public/icons/download/chrome.svg create mode 100644 apps/web/public/icons/download/edge.svg create mode 100644 apps/web/public/icons/download/firefox.svg create mode 100644 apps/web/public/icons/download/linux.svg create mode 100644 apps/web/public/icons/download/play-store.svg create mode 100644 apps/web/public/icons/download/safari.svg create mode 100644 apps/web/public/icons/download/windows.svg diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index c0e05a0cae9096..42981d2915e785 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -234,7 +234,9 @@ export function UserDropdown({ small }: UserDropdownProps) { + } target="_blank" rel="noreferrer" href={DOWNLOAD_LINKS.ios}> @@ -243,7 +245,9 @@ export function UserDropdown({ small }: UserDropdownProps) { + } target="_blank" rel="noreferrer" href={DOWNLOAD_LINKS.android}> @@ -253,7 +257,9 @@ export function UserDropdown({ small }: UserDropdownProps) { {browser === "chrome" && ( + } target="_blank" rel="noreferrer" href={DOWNLOAD_LINKS.chrome}> @@ -264,7 +270,9 @@ export function UserDropdown({ small }: UserDropdownProps) { {browser === "safari" && ( + } target="_blank" rel="noreferrer" href={DOWNLOAD_LINKS.safari}> @@ -275,7 +283,9 @@ export function UserDropdown({ small }: UserDropdownProps) { {browser === "firefox" && ( + } target="_blank" rel="noreferrer" href={DOWNLOAD_LINKS.firefox}> @@ -286,7 +296,9 @@ export function UserDropdown({ small }: UserDropdownProps) { {browser === "edge" && ( + } target="_blank" rel="noreferrer" href={DOWNLOAD_LINKS.edge}> @@ -297,7 +309,9 @@ export function UserDropdown({ small }: UserDropdownProps) { {os === "macos" && ( + } target="_blank" rel="noreferrer" href={DOWNLOAD_LINKS.macos}> @@ -308,7 +322,9 @@ export function UserDropdown({ small }: UserDropdownProps) { {os === "windows" && ( + } target="_blank" rel="noreferrer" href={DOWNLOAD_LINKS.windows}> @@ -319,7 +335,9 @@ export function UserDropdown({ small }: UserDropdownProps) { {os === "linux" && ( + } target="_blank" rel="noreferrer" href={DOWNLOAD_LINKS.linux}> diff --git a/apps/web/public/icons/download/apple-white.svg b/apps/web/public/icons/download/apple-white.svg new file mode 100644 index 00000000000000..d216c24d71fd0b --- /dev/null +++ b/apps/web/public/icons/download/apple-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/icons/download/apple.svg b/apps/web/public/icons/download/apple.svg new file mode 100644 index 00000000000000..a95364556d0a04 --- /dev/null +++ b/apps/web/public/icons/download/apple.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/icons/download/chrome.svg b/apps/web/public/icons/download/chrome.svg new file mode 100644 index 00000000000000..32c7ab5ae5749e --- /dev/null +++ b/apps/web/public/icons/download/chrome.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/icons/download/edge.svg b/apps/web/public/icons/download/edge.svg new file mode 100644 index 00000000000000..c8b0dff10e1f52 --- /dev/null +++ b/apps/web/public/icons/download/edge.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/icons/download/firefox.svg b/apps/web/public/icons/download/firefox.svg new file mode 100644 index 00000000000000..e75441119565c1 --- /dev/null +++ b/apps/web/public/icons/download/firefox.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/icons/download/linux.svg b/apps/web/public/icons/download/linux.svg new file mode 100644 index 00000000000000..86f61d97afade9 --- /dev/null +++ b/apps/web/public/icons/download/linux.svg @@ -0,0 +1,563 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/icons/download/play-store.svg b/apps/web/public/icons/download/play-store.svg new file mode 100644 index 00000000000000..3d8b500b2e3467 --- /dev/null +++ b/apps/web/public/icons/download/play-store.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/icons/download/safari.svg b/apps/web/public/icons/download/safari.svg new file mode 100644 index 00000000000000..aa5c3a0ee90a5c --- /dev/null +++ b/apps/web/public/icons/download/safari.svg @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/icons/download/windows.svg b/apps/web/public/icons/download/windows.svg new file mode 100644 index 00000000000000..8e89b5092328de --- /dev/null +++ b/apps/web/public/icons/download/windows.svg @@ -0,0 +1,10 @@ + + + + + + + + + + From 1d4b027499acf0ad9763ddfab815830be5ae71cd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:49:20 +0000 Subject: [PATCH 07/21] refactor: migrate UserDropdown to coss-ui Menu components Replace Radix UI dropdown components with coss-ui Menu components: - Menu, MenuTrigger, MenuPopup, MenuItem, MenuSeparator - MenuSub, MenuSubTrigger, MenuSubPopup for submenu - Use render prop pattern instead of asChild - Update test mocks to use coss-ui components Co-Authored-By: peer@cal.com --- .../shell/user-dropdown/UserDropdown.test.tsx | 29 +- .../shell/user-dropdown/UserDropdown.tsx | 443 ++++++++---------- 2 files changed, 201 insertions(+), 271 deletions(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx index bce3b58f9e9cf9..241a02c1bd4885 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx @@ -47,17 +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}
, - DropdownMenuSub: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownMenuSubTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownMenuSubContent: ({ 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", () => ({ @@ -243,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 () => { @@ -255,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 42981d2915e785..628907aa565511 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -4,22 +4,20 @@ 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, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - 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"; @@ -35,7 +33,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 @@ -121,260 +118,190 @@ export function UserDropdown({ small }: UserDropdownProps) { } return ( - - setMenuOpen((menuOpen) => !menuOpen)} disabled={isPending}> - - +
+ )} + - - - { - setMenuOpen(false); - }} - className="group overflow-hidden rounded-md"> + + + {!isPlatformPages && ( <> - {!isPlatformPages && ( - <> - - - - - - - - - - - - )} + } + className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> + + } + className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> + + } + className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> + + + + )} - - - {t("visit_roadmap")} - - - - - - {!isPlatformPages && ( - - - - - {t("download_app")} - - - - - - } - target="_blank" - rel="noreferrer" - href={DOWNLOAD_LINKS.ios}> - {t("download_for_ios")} - - - - - } - target="_blank" - rel="noreferrer" - href={DOWNLOAD_LINKS.android}> - {t("download_for_android")} - - - {browser === "chrome" && ( - - - } - target="_blank" - rel="noreferrer" - href={DOWNLOAD_LINKS.chrome}> - {t("download_chrome_extension")} - - - )} - {browser === "safari" && ( - - - } - target="_blank" - rel="noreferrer" - href={DOWNLOAD_LINKS.safari}> - {t("download_safari_extension")} - - - )} - {browser === "firefox" && ( - - - } - target="_blank" - rel="noreferrer" - href={DOWNLOAD_LINKS.firefox}> - {t("download_firefox_extension")} - - - )} - {browser === "edge" && ( - - - } - target="_blank" - rel="noreferrer" - href={DOWNLOAD_LINKS.edge}> - {t("download_edge_extension")} - - - )} - {os === "macos" && ( - - - } - target="_blank" - rel="noreferrer" - href={DOWNLOAD_LINKS.macos}> - {t("download_for_macos")} - - - )} - {os === "windows" && ( - - - } - target="_blank" - rel="noreferrer" - href={DOWNLOAD_LINKS.windows}> - {t("download_for_windows")} - - - )} - {os === "linux" && ( - - - } - target="_blank" - rel="noreferrer" - href={DOWNLOAD_LINKS.linux}> - {t("download_for_linux")} - - - )} - - - )} + } + className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> + + {t("visit_roadmap")} + + + + {!isPlatformPages && ( + + + + {t("download_app")} + + + } + className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> + + {t("download_for_ios")} + + } + className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> + + {t("download_for_android")} + + {browser === "chrome" && ( + } + className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> + + {t("download_chrome_extension")} + + )} + {browser === "safari" && ( + } + className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> + + {t("download_safari_extension")} + + )} + {browser === "firefox" && ( + } + className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> + + {t("download_firefox_extension")} + + )} + {browser === "edge" && ( + } + className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> + + {t("download_edge_extension")} + + )} + {os === "macos" && ( + } + className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> + + {t("download_for_macos")} + + )} + {os === "windows" && ( + } + className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> + + {t("download_for_windows")} + + )} + {os === "linux" && ( + } + className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> + + {t("download_for_linux")} + + )} + + + )} - {!isPlatformPages && isPlatformUser && ( - - - Platform - - - )} - + {!isPlatformPages && isPlatformUser && ( + } + className="todesktop:hidden hover:bg-subtle hover:text-emphasis text-default hidden w-full items-center gap-2 rounded-lg p-2 text-sm font-medium lg:flex"> + + Platform + + )} + - - - - - - - - + { + signOut({ callbackUrl: "/auth/logout" }); + }} + className="hover:bg-error hover:text-error text-error flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium" + aria-hidden="true"> + + {t("sign_out")} + + + + ); } From f24d0a5e08ce50f7b8f549e4a4506fb14e992dc8 Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Mon, 26 Jan 2026 13:55:46 +0100 Subject: [PATCH 08/21] refactor: improve menu component --- .../shell/user-dropdown/UserDropdown.tsx | 94 +++++++------------ 1 file changed, 33 insertions(+), 61 deletions(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index 628907aa565511..82c2b80cd6d22f 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -168,112 +168,84 @@ export function UserDropdown({ small }: UserDropdownProps) { {!isPlatformPages && ( <> - } - className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> - From 956abb7caedd3d053009bbe42c3d3ba88051102e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:06:25 +0000 Subject: [PATCH 09/21] fix: convert Apple icon to inline SVG for dark/light mode support Replace img tags with inline SVG for Apple icons (iOS and macOS downloads) to support dark/light mode color switching via Tailwind fill-foreground class. Co-Authored-By: peer@cal.com --- .../shell/user-dropdown/UserDropdown.tsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index 628907aa565511..a3ddb931abf8d0 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -212,7 +212,16 @@ export function UserDropdown({ small }: UserDropdownProps) { } className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> - + {t("download_for_ios")} } className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> - + {t("download_for_macos")} )} From 34aeee462d32430b9991c285eda822e0e7a999bd Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Mon, 26 Jan 2026 14:10:38 +0100 Subject: [PATCH 10/21] chore: remove unnneeded class --- apps/web/modules/shell/user-dropdown/UserDropdown.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index ef0bdb2603ab53..eff97d8969d9e2 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -206,7 +206,7 @@ export function UserDropdown({ small }: UserDropdownProps) { viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" - className="h-4 w-4 fill-foreground" + className="fill-foreground" aria-hidden="true"> @@ -249,7 +249,7 @@ export function UserDropdown({ small }: UserDropdownProps) { viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" - className="h-4 w-4 fill-foreground" + className="fill-foreground" aria-hidden="true"> From c2a88241adeaba9405c4a7faa6b878ed3494aa6e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:19:54 +0000 Subject: [PATCH 11/21] refactor: convert all download icons to inline SVG components Co-Authored-By: peer@cal.com --- .../shell/user-dropdown/DownloadIcons.tsx | 625 ++++++++++++++++++ .../shell/user-dropdown/UserDropdown.tsx | 47 +- 2 files changed, 645 insertions(+), 27 deletions(-) create mode 100644 apps/web/modules/shell/user-dropdown/DownloadIcons.tsx 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.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index a3ddb931abf8d0..7aa8fa634fe8d1 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -23,6 +23,17 @@ 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 { Support?: { @@ -212,29 +223,20 @@ export function UserDropdown({ small }: UserDropdownProps) { } className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> - + {t("download_for_ios")} } className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> - + {t("download_for_android")} {browser === "chrome" && ( } className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> - + {t("download_chrome_extension")} )} @@ -242,7 +244,7 @@ export function UserDropdown({ small }: UserDropdownProps) { } className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> - + {t("download_safari_extension")} )} @@ -250,7 +252,7 @@ export function UserDropdown({ small }: UserDropdownProps) { } className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> - + {t("download_firefox_extension")} )} @@ -258,7 +260,7 @@ export function UserDropdown({ small }: UserDropdownProps) { } className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> - + {t("download_edge_extension")} )} @@ -266,16 +268,7 @@ export function UserDropdown({ small }: UserDropdownProps) { } className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> - + {t("download_for_macos")} )} @@ -283,7 +276,7 @@ export function UserDropdown({ small }: UserDropdownProps) { } className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> - + {t("download_for_windows")} )} @@ -291,7 +284,7 @@ export function UserDropdown({ small }: UserDropdownProps) { } className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> - + {t("download_for_linux")} )} From b82b00ed0b37e194458eceea17e6a07dcead4fe8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:48:56 +0000 Subject: [PATCH 12/21] refactor: remove classes from MenuItem and use size-4 for SVG icons Co-Authored-By: peer@cal.com --- .../shell/user-dropdown/UserDropdown.tsx | 98 +++++++------------ 1 file changed, 34 insertions(+), 64 deletions(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index 7aa8fa634fe8d1..cde9b3c9d9d90e 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -179,112 +179,84 @@ export function UserDropdown({ small }: UserDropdownProps) { {!isPlatformPages && ( <> - } - className="hover:bg-subtle hover:text-emphasis text-default flex w-full items-center gap-2 rounded-lg p-2 text-sm font-medium"> - From 91146110ba69184271ee0e601a1c5c2c4c7db1ca Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Mon, 26 Jan 2026 14:52:14 +0100 Subject: [PATCH 13/21] mc --- apps/web/modules/shell/user-dropdown/UserDropdown.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index cde9b3c9d9d90e..9679a908c197c8 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -176,7 +176,7 @@ export function UserDropdown({ small }: UserDropdownProps) { - + {!isPlatformPages && ( <> }> @@ -275,6 +275,7 @@ export function UserDropdown({ small }: UserDropdownProps) { { signOut({ callbackUrl: "/auth/logout" }); }}> From cdf1a2f730ee2f00bcdeac9cc4287dd3a368ef35 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:57:39 +0000 Subject: [PATCH 14/21] refactor: hide iOS/Android download links on desktop devices Co-Authored-By: peer@cal.com --- .../shell/user-dropdown/UserDropdown.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index cde9b3c9d9d90e..f187e064ce9c03 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -72,7 +72,7 @@ export function UserDropdown({ small }: UserDropdownProps) { const { data: user, isPending } = useMeQuery(); const pathname = usePathname(); const isPlatformPages = pathname?.startsWith("/settings/platform"); - const { os, browser } = useUserAgentData(); + const { os, browser, isMobile } = useUserAgentData(); useEffect(() => { if (typeof window === "undefined") return; @@ -210,14 +210,18 @@ export function UserDropdown({ small }: UserDropdownProps) { {t("download_app")} - }> - - {t("download_for_ios")} - - }> - - {t("download_for_android")} - + {isMobile && ( + }> + + {t("download_for_ios")} + + )} + {isMobile && ( + }> + + {t("download_for_android")} + + )} {browser === "chrome" && ( }> From 4050eff0c7e36a1da4dce3f77f59678551bd093a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:03:11 +0000 Subject: [PATCH 15/21] refactor: show platform-specific download links only (iOS for iOS, Android for Android, etc.) Co-Authored-By: peer@cal.com --- .../web/modules/shell/user-dropdown/UserDropdown.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index af87732b6a64be..c1639f9188b2c0 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -210,37 +210,37 @@ export function UserDropdown({ small }: UserDropdownProps) { {t("download_app")} - {isMobile && ( + {os === "ios" && ( }> {t("download_for_ios")} )} - {isMobile && ( + {os === "android" && ( }> {t("download_for_android")} )} - {browser === "chrome" && ( + {!isMobile && browser === "chrome" && ( }> {t("download_chrome_extension")} )} - {browser === "safari" && ( + {!isMobile && browser === "safari" && ( }> {t("download_safari_extension")} )} - {browser === "firefox" && ( + {!isMobile && browser === "firefox" && ( }> {t("download_firefox_extension")} )} - {browser === "edge" && ( + {!isMobile && browser === "edge" && ( }> {t("download_edge_extension")} From ba973b26be570a29ebf1b8b3fe3a764e721efd5f Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Mon, 26 Jan 2026 15:05:52 +0100 Subject: [PATCH 16/21] chore: keep the download menu item visible on smaller screens --- apps/web/modules/shell/user-dropdown/UserDropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index c1639f9188b2c0..ad293a9d20bb67 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -205,7 +205,7 @@ export function UserDropdown({ small }: UserDropdownProps) { {!isPlatformPages && ( - + {t("download_app")} From 77168f17a685f3fc268caeb686ec4d3fd8f9c797 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:13:03 +0000 Subject: [PATCH 17/21] refactor: show iOS/Android on desktop, platform-specific on mobile Co-Authored-By: peer@cal.com --- apps/web/modules/shell/user-dropdown/UserDropdown.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index c1639f9188b2c0..f923d3a2b695e3 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -210,13 +210,13 @@ export function UserDropdown({ small }: UserDropdownProps) { {t("download_app")} - {os === "ios" && ( + {(os === "ios" || !isMobile) && ( }> {t("download_for_ios")} )} - {os === "android" && ( + {(os === "android" || !isMobile) && ( }> {t("download_for_android")} From ab4428ad02afa8f5c3cdd532750e63b9e40aeb98 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:37:04 +0000 Subject: [PATCH 18/21] refactor: simplify download menu - direct link on mobile, submenu on desktop Co-Authored-By: peer@cal.com --- .../shell/user-dropdown/UserDropdown.tsx | 70 +++++++++++-------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index 038dd72c92c4dd..3a28f5777097ed 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -203,67 +203,75 @@ export function UserDropdown({ small }: UserDropdownProps) { {t("help")} - {!isPlatformPages && ( + {!isPlatformPages && isMobile && os === "ios" && ( + }> + + {t("download_app")} + + )} + {!isPlatformPages && isMobile && os === "android" && ( + }> + + {t("download_app")} + + )} + {!isPlatformPages && !isMobile && ( - + {t("download_app")} - {(os === "ios" || !isMobile) && ( - }> + {os === "macos" && ( + }> - {t("download_for_ios")} + {t("download_for_macos")} + + )} + {os === "windows" && ( + }> + + {t("download_for_windows")} )} - {(os === "android" || !isMobile) && ( - }> - - {t("download_for_android")} + {os === "linux" && ( + }> + + {t("download_for_linux")} )} - {!isMobile && browser === "chrome" && ( + {browser === "chrome" && ( }> {t("download_chrome_extension")} )} - {!isMobile && browser === "safari" && ( + {browser === "safari" && ( }> {t("download_safari_extension")} )} - {!isMobile && browser === "firefox" && ( + {browser === "firefox" && ( }> {t("download_firefox_extension")} )} - {!isMobile && browser === "edge" && ( + {browser === "edge" && ( }> {t("download_edge_extension")} )} - {os === "macos" && ( - }> - - {t("download_for_macos")} - - )} - {os === "windows" && ( - }> - - {t("download_for_windows")} - - )} - {os === "linux" && ( - }> - - {t("download_for_linux")} - - )} + }> + + {t("download_for_ios")} + + }> + + {t("download_for_android")} + )} From 76f68acd2370faa83d82f3d330bb259e15e60142 Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Mon, 26 Jan 2026 15:40:48 +0100 Subject: [PATCH 19/21] mc --- apps/web/modules/shell/user-dropdown/UserDropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index 3a28f5777097ed..30be66522bae61 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -217,7 +217,7 @@ export function UserDropdown({ small }: UserDropdownProps) { )} {!isPlatformPages && !isMobile && ( - + {t("download_app")} From eab940ecac436bc9f32e46e25a5f120c0dc12332 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:48:56 +0000 Subject: [PATCH 20/21] chore: remove unused SVG files (now inline in DownloadIcons.tsx) Co-Authored-By: peer@cal.com --- .../web/public/icons/download/apple-white.svg | 3 - apps/web/public/icons/download/apple.svg | 3 - apps/web/public/icons/download/chrome.svg | 21 - apps/web/public/icons/download/edge.svg | 46 -- apps/web/public/icons/download/firefox.svg | 106 ---- apps/web/public/icons/download/linux.svg | 563 ------------------ apps/web/public/icons/download/play-store.svg | 65 -- apps/web/public/icons/download/safari.svg | 158 ----- apps/web/public/icons/download/windows.svg | 10 - 9 files changed, 975 deletions(-) delete mode 100644 apps/web/public/icons/download/apple-white.svg delete mode 100644 apps/web/public/icons/download/apple.svg delete mode 100644 apps/web/public/icons/download/chrome.svg delete mode 100644 apps/web/public/icons/download/edge.svg delete mode 100644 apps/web/public/icons/download/firefox.svg delete mode 100644 apps/web/public/icons/download/linux.svg delete mode 100644 apps/web/public/icons/download/play-store.svg delete mode 100644 apps/web/public/icons/download/safari.svg delete mode 100644 apps/web/public/icons/download/windows.svg diff --git a/apps/web/public/icons/download/apple-white.svg b/apps/web/public/icons/download/apple-white.svg deleted file mode 100644 index d216c24d71fd0b..00000000000000 --- a/apps/web/public/icons/download/apple-white.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/public/icons/download/apple.svg b/apps/web/public/icons/download/apple.svg deleted file mode 100644 index a95364556d0a04..00000000000000 --- a/apps/web/public/icons/download/apple.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/public/icons/download/chrome.svg b/apps/web/public/icons/download/chrome.svg deleted file mode 100644 index 32c7ab5ae5749e..00000000000000 --- a/apps/web/public/icons/download/chrome.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/icons/download/edge.svg b/apps/web/public/icons/download/edge.svg deleted file mode 100644 index c8b0dff10e1f52..00000000000000 --- a/apps/web/public/icons/download/edge.svg +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/icons/download/firefox.svg b/apps/web/public/icons/download/firefox.svg deleted file mode 100644 index e75441119565c1..00000000000000 --- a/apps/web/public/icons/download/firefox.svg +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/icons/download/linux.svg b/apps/web/public/icons/download/linux.svg deleted file mode 100644 index 86f61d97afade9..00000000000000 --- a/apps/web/public/icons/download/linux.svg +++ /dev/null @@ -1,563 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/icons/download/play-store.svg b/apps/web/public/icons/download/play-store.svg deleted file mode 100644 index 3d8b500b2e3467..00000000000000 --- a/apps/web/public/icons/download/play-store.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/icons/download/safari.svg b/apps/web/public/icons/download/safari.svg deleted file mode 100644 index aa5c3a0ee90a5c..00000000000000 --- a/apps/web/public/icons/download/safari.svg +++ /dev/null @@ -1,158 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/icons/download/windows.svg b/apps/web/public/icons/download/windows.svg deleted file mode 100644 index 8e89b5092328de..00000000000000 --- a/apps/web/public/icons/download/windows.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - From 609ecc127df32efb46173be6a73d9203bc472dac Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:06:33 +0000 Subject: [PATCH 21/21] test: add comprehensive tests for useUserAgentData hook Co-Authored-By: peer@cal.com --- packages/lib/hooks/useUserAgentData.test.ts | 174 ++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 packages/lib/hooks/useUserAgentData.test.ts 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); + }); + }); +});