From a7f591fe6d5da5a56a56bf14a97aab859aad100e Mon Sep 17 00:00:00 2001 From: Duncan Crawbuck Date: Wed, 20 May 2026 16:41:59 -0700 Subject: [PATCH 1/2] Refine docs section navigation --- src/components/DocsHeader.tsx | 380 ++++++++++++++++++++++++++++++++++ src/lib/layout.shared.tsx | 4 - src/routes/$.tsx | 16 +- src/routes/index.tsx | 7 + 4 files changed, 398 insertions(+), 9 deletions(-) create mode 100644 src/components/DocsHeader.tsx diff --git a/src/components/DocsHeader.tsx b/src/components/DocsHeader.tsx new file mode 100644 index 0000000..12612f8 --- /dev/null +++ b/src/components/DocsHeader.tsx @@ -0,0 +1,380 @@ +"use client"; + +import { cn } from "@/lib/cn"; +import { buttonVariants } from "fumadocs-ui/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "fumadocs-ui/components/ui/popover"; +import { + isLayoutTabActive, + LinkItem, + type LayoutTab, + type LinkItemType, +} from "fumadocs-ui/layouts/shared"; +import { useNotebookLayout } from "fumadocs-ui/layouts/notebook"; +import { usePathname } from "fumadocs-core/framework"; +import Link from "fumadocs-core/link"; +import { ChevronDown, Languages, Sidebar } from "lucide-react"; +import { Fragment, useMemo, useRef, useState, type ComponentProps, type PointerEvent } from "react"; + +const SDK_TAB_LABELS: Record = { + ios: "iOS", + android: "Android", + expo: "Expo", + flutter: "Flutter", + unity: "Unity", + "react-native": "React Native", + community: "Community", +}; + +const SDK_TAB_SLUGS = new Set(Object.keys(SDK_TAB_LABELS)); + +function getTabPrefix(url: string) { + return url.replace(/^\/docs\//, "").split("/")[0] ?? ""; +} + +function isSdkTab(tab: LayoutTab) { + return SDK_TAB_SLUGS.has(getTabPrefix(tab.url)); +} + +function getSdkTabLabel(tab: LayoutTab) { + const prefix = getTabPrefix(tab.url); + return SDK_TAB_LABELS[prefix] ?? String(tab.title); +} + +function getSdkTabDescription(tab: LayoutTab) { + return tab.description; +} + +export function DocsHeader(props: ComponentProps<"header">) { + const { + slots, + navItems, + isNavTransparent, + props: { tabMode, nav, tabs, sidebar }, + } = useNotebookLayout(); + const { open } = slots.sidebar?.useSidebar?.() ?? {}; + const navMode = nav?.mode ?? "auto"; + const sidebarCollapsible = sidebar.collapsible ?? true; + const showLayoutTabs = tabMode === "navbar" && tabs.length > 0; + + if (nav?.component) return nav.component; + + return ( +
+
+
+ {sidebarCollapsible && slots.sidebar && navMode === "auto" && ( + + + + )} + {slots.navTitle && ( + + )} + {nav?.children} +
+ + {slots.searchTrigger && ( + + )} + +
+
+ {navItems + .filter((item) => item.type !== "icon") + .map((item, i) => ( + + ))} +
+ {navItems + .filter((item) => item.type === "icon") + .map((item, i) => ( + + {item.icon} + + ))} +
+ {slots.searchTrigger && } + {slots.sidebar && ( + + + + )} +
+
+ {slots.languageSelect && ( + + + + )} + {slots.themeSwitch && } + {sidebarCollapsible && slots.sidebar && navMode === "top" && ( + + + + )} +
+
+
+ + {showLayoutTabs && ( + + )} +
+ ); +} + +function LayoutHeaderTabs({ + tabs, + className, + ...props +}: ComponentProps<"div"> & { tabs: LayoutTab[] }) { + const pathname = usePathname(); + const selectedIdx = useMemo( + () => tabs.findLastIndex((option) => isLayoutTabActive(option, pathname)), + [tabs, pathname], + ); + const sdkTabs = tabs.filter(isSdkTab); + const firstSdkIndex = tabs.findIndex(isSdkTab); + + return ( +
+ {tabs.map((option, i) => { + if (isSdkTab(option)) { + if (i !== firstSdkIndex) return null; + return ; + } + + const { title, url, unlisted, props: { className, ...rest } = {} } = option; + const isSelected = selectedIdx === i; + const icon = option.props?.children ?? option.icon; + return ( + + + {icon} + + {title} + + ); + })} +
+ ); +} + +function SdkTabsDropdown({ tabs, pathname }: { tabs: LayoutTab[]; pathname: string }) { + const [open, setOpen] = useState(false); + const selected = tabs.findLast((tab) => isLayoutTabActive(tab, pathname)); + const isSelected = Boolean(selected); + const selectedIcon = selected?.props?.children ?? selected?.icon; + + return ( + + + SDK + {selected && ( + + + {selectedIcon} + + {getSdkTabLabel(selected)} + + )} + + + + + + {tabs.map((tab) => { + const active = isLayoutTabActive(tab, pathname); + const icon = tab.props?.children ?? tab.icon; + const description = getSdkTabDescription(tab); + return ( + setOpen(false)} + key={tab.url} + > + + {icon} + + + {getSdkTabLabel(tab)} + {description && ( + + {description} + + )} + + + ); + })} + + + ); +} + +function NavbarLinkItem({ + item, + className, + ...props +}: ComponentProps<"a"> & { item: LinkItemType }) { + if (item.type === "custom") return item.children; + if (item.type === "menu") return ; + + return ( + + {item.text} + + ); +} + +function NavbarLinkItemMenu({ + item, + hoverDelay = 50, + className, + ...props +}: ComponentProps<"button"> & { + item: Extract; + hoverDelay?: number; +}) { + const [open, setOpen] = useState(false); + const timeoutRef = useRef(null); + const freezeUntil = useRef(null); + const delaySetOpen = (value: boolean) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + timeoutRef.current = window.setTimeout(() => { + setOpen(value); + freezeUntil.current = Date.now() + 300; + }, hoverDelay); + }; + const onPointerEnter = (e: PointerEvent) => { + if (e.pointerType === "touch") return; + delaySetOpen(true); + }; + const onPointerLeave = (e: PointerEvent) => { + if (e.pointerType === "touch") return; + delaySetOpen(false); + }; + + return ( + { + if (freezeUntil.current === null || Date.now() >= freezeUntil.current) setOpen(value); + }} + > + + {item.url ? {item.text} : item.text} + + + + {item.items.map((child, i) => { + if (child.type === "custom") return {child.children}; + return ( + { + if ("ontouchstart" in window || navigator.maxTouchPoints > 0) setOpen(false); + }} + key={i} + > + {child.icon} + {child.text} + + ); + })} + + + ); +} diff --git a/src/lib/layout.shared.tsx b/src/lib/layout.shared.tsx index ab03746..876eb61 100644 --- a/src/lib/layout.shared.tsx +++ b/src/lib/layout.shared.tsx @@ -19,10 +19,6 @@ export function baseOptions(): BaseLayoutProps { ), }, links: [ - { - text: "Integrations", - url: buildDocsPath("integrations"), - }, { text: "Changelog", url: buildDocsPath("changelog"), diff --git a/src/routes/$.tsx b/src/routes/$.tsx index 13a00f2..b1f184c 100644 --- a/src/routes/$.tsx +++ b/src/routes/$.tsx @@ -13,6 +13,7 @@ import { SidebarDiscordLink } from "@/components/SidebarDiscordLink"; import { Feedback } from "@/components/feedback/client"; import type { ActionResponse, PageFeedback } from "@/components/feedback/schema"; import { LoginStatusProvider } from "@/components/LoginStatusContext"; +import { DocsHeader } from "@/components/DocsHeader"; import { buildCanonicalUrl, DEFAULT_DESCRIPTION, @@ -149,7 +150,7 @@ const clientLoader = browserCollections.docs.createClientLoader({ ); }, }); -const NAVBAR_ONLY_TABS = new Set(["integrations", "support", "changelog"]); +const NAVBAR_ONLY_TABS = new Set(["support", "changelog"]); const SDK_CONTEXT_SET = new Set(SDK_CONTEXT_SLUGS); function parseSDKSubPath(url: string): string | null { @@ -192,17 +193,22 @@ function Page() { return { ...option, url, - title: ( - <> - {option.icon} {option.title} - + icon: ( + + {option.icon} + ), + props: { + ...option.props, + children: option.icon, + }, }; }, }, prefetch: true, footer: , }} + slots={{ header: DocsHeader }} > {clientLoader.useContent(data.path, data)} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 4cb29e6..5d24b0b 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -9,6 +9,7 @@ import { History, AlertTriangle, Users, + Cpu, } from "lucide-react"; import type { SVGProps, ReactNode } from "react"; import { @@ -104,6 +105,12 @@ const docsCards: DocCard[] = [ href: buildDocsPath("dashboard"), icon: , }, + { + title: "Superwall Agents", + description: "Analyze experiments, automate reports, connect tools, and work with hosted machines.", + href: buildDocsPath("agents"), + icon: , + }, { title: "Web Checkout", description: "Let customers purchase products online via Stripe, then link them to your app.", From ac58b710df8dedaed2607d300e4f653253f1f4da Mon Sep 17 00:00:00 2001 From: Duncan Crawbuck Date: Fri, 22 May 2026 17:46:01 -0700 Subject: [PATCH 2/2] Move SDK selector first --- src/components/DocsHeader.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/DocsHeader.tsx b/src/components/DocsHeader.tsx index 12612f8..386b616 100644 --- a/src/components/DocsHeader.tsx +++ b/src/components/DocsHeader.tsx @@ -184,15 +184,12 @@ function LayoutHeaderTabs({ [tabs, pathname], ); const sdkTabs = tabs.filter(isSdkTab); - const firstSdkIndex = tabs.findIndex(isSdkTab); return (
+ {sdkTabs.length > 0 && } {tabs.map((option, i) => { - if (isSdkTab(option)) { - if (i !== firstSdkIndex) return null; - return ; - } + if (isSdkTab(option)) return null; const { title, url, unlisted, props: { className, ...rest } = {} } = option; const isSelected = selectedIdx === i;