Skip to content

Commit b7e0863

Browse files
authored
feat(docs): add product switcher linking to docs.deco.cx (#3258)
* feat(docs): add product switcher linking to docs.deco.cx Replaces the static logo in the sidebar/mobile header with a clickable product switcher. Users frequently land on docs.decocms.com when they're actually looking for docs.deco.cx (the storefront platform docs); there was no in-product escape hatch before. The switcher is a popover anchored to the deco logo with two entries: decocms (current, marked active) and deco.cx (external link, opens in a new tab). Closes on outside click and Escape, returns focus to the trigger. The product list lives in a new config/products.ts so adding a third docs property later is a one-line change. Made-with: Cursor * [chore]: apply biome formatting to ProductSwitcher Made-with: Cursor
1 parent 73d64e6 commit b7e0863

4 files changed

Lines changed: 193 additions & 4 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { useEffect, useRef, useState } from "react";
2+
import { Logo } from "../atoms/Logo";
3+
import { Icon } from "../atoms/Icon";
4+
import {
5+
products,
6+
CURRENT_PRODUCT_ID,
7+
type Product,
8+
} from "../../config/products";
9+
10+
interface ProductSwitcherProps {
11+
/** Which product is "this site". Defaults to decocms. */
12+
current?: string;
13+
className?: string;
14+
}
15+
16+
export function ProductSwitcher({
17+
current = CURRENT_PRODUCT_ID,
18+
className = "",
19+
}: ProductSwitcherProps) {
20+
const [open, setOpen] = useState(false);
21+
const containerRef = useRef<HTMLDivElement>(null);
22+
const triggerRef = useRef<HTMLButtonElement>(null);
23+
24+
useEffect(() => {
25+
if (!open) return;
26+
27+
const handleClickOutside = (event: MouseEvent) => {
28+
if (
29+
containerRef.current &&
30+
!containerRef.current.contains(event.target as Node)
31+
) {
32+
setOpen(false);
33+
}
34+
};
35+
36+
const handleKeyDown = (event: KeyboardEvent) => {
37+
if (event.key === "Escape") {
38+
setOpen(false);
39+
triggerRef.current?.focus();
40+
}
41+
};
42+
43+
document.addEventListener("mousedown", handleClickOutside);
44+
document.addEventListener("keydown", handleKeyDown);
45+
return () => {
46+
document.removeEventListener("mousedown", handleClickOutside);
47+
document.removeEventListener("keydown", handleKeyDown);
48+
};
49+
}, [open]);
50+
51+
return (
52+
<div ref={containerRef} className={`relative ${className}`}>
53+
<button
54+
ref={triggerRef}
55+
type="button"
56+
onClick={() => setOpen((value) => !value)}
57+
aria-expanded={open}
58+
aria-haspopup="menu"
59+
aria-label="Switch product docs"
60+
className="flex items-center gap-1.5 px-1.5 py-1 -mx-1.5 -my-1 rounded-md hover:bg-muted transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-ring"
61+
>
62+
<Logo width={67} height={28} />
63+
<Icon
64+
name="ChevronDown"
65+
size={14}
66+
className={`text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`}
67+
/>
68+
</button>
69+
70+
{open && (
71+
<div
72+
role="menu"
73+
aria-label="Product docs"
74+
className="absolute left-0 top-[calc(100%+8px)] z-50 w-64 rounded-lg border border-border bg-app-background shadow-lg overflow-hidden"
75+
>
76+
<div className="px-3 py-2 border-b border-border/60">
77+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
78+
Docs
79+
</span>
80+
</div>
81+
<div className="py-1">
82+
{products.map((product) => (
83+
<ProductMenuItem
84+
key={product.id}
85+
product={product}
86+
isCurrent={product.id === current}
87+
onSelect={() => setOpen(false)}
88+
/>
89+
))}
90+
</div>
91+
</div>
92+
)}
93+
</div>
94+
);
95+
}
96+
97+
interface ProductMenuItemProps {
98+
product: Product;
99+
isCurrent: boolean;
100+
onSelect: () => void;
101+
}
102+
103+
function ProductMenuItem({
104+
product,
105+
isCurrent,
106+
onSelect,
107+
}: ProductMenuItemProps) {
108+
const sharedClasses = `flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${
109+
isCurrent ? "bg-primary/5" : "hover:bg-muted"
110+
}`;
111+
112+
const body = (
113+
<>
114+
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
115+
<span
116+
className={`text-sm font-medium truncate ${
117+
isCurrent ? "text-primary" : "text-foreground"
118+
}`}
119+
>
120+
{product.label}
121+
</span>
122+
<span className="text-xs text-muted-foreground truncate">
123+
{product.description}
124+
</span>
125+
</div>
126+
{isCurrent ? (
127+
<Icon name="Check" size={16} className="text-primary shrink-0" />
128+
) : product.external ? (
129+
<Icon
130+
name="ArrowUpRight"
131+
size={16}
132+
className="text-muted-foreground shrink-0"
133+
/>
134+
) : null}
135+
</>
136+
);
137+
138+
if (isCurrent || !product.href) {
139+
return (
140+
<div
141+
role="menuitem"
142+
aria-current={isCurrent ? "true" : undefined}
143+
className={sharedClasses}
144+
>
145+
{body}
146+
</div>
147+
);
148+
}
149+
150+
return (
151+
<a
152+
href={product.href}
153+
target={product.external ? "_blank" : undefined}
154+
rel={product.external ? "noopener noreferrer" : undefined}
155+
role="menuitem"
156+
className={sharedClasses}
157+
onClick={onSelect}
158+
>
159+
{body}
160+
</a>
161+
);
162+
}

apps/docs/client/src/components/ui/Sidebar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import React, { useEffect, useState } from "react";
22
import { navigate } from "astro:transitions/client";
3-
import { Logo } from "../../components/atoms/Logo";
43
import { Icon } from "../../components/atoms/Icon";
54
import { Select } from "../../components/atoms/Select";
65
import { LanguageSelector } from "./LanguageSelector";
6+
import { ProductSwitcher } from "./ProductSwitcher";
77
import { ThemeToggle } from "./ThemeToggle";
88
import { versions, VERSION_IDS, LATEST_VERSION } from "../../config/versions";
99

@@ -553,7 +553,7 @@ export default function Sidebar({
553553
<div className="flex flex-col h-screen bg-app-background border-r border-border w-[19rem] lg:w-[19rem] w-full max-w-[19rem]">
554554
{/* Header - hidden on mobile */}
555555
<div className="hidden lg:flex items-center justify-between px-4 lg:px-6 py-3 shrink-0 border-b border-border">
556-
<Logo width={67} height={28} />
556+
<ProductSwitcher />
557557
<div className="flex items-center gap-1.5">
558558
<LanguageSelector locale={locale} compact />
559559
<ThemeToggle />
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export interface Product {
2+
id: string;
3+
label: string;
4+
description: string;
5+
/** Absolute URL. `null` for the current product. */
6+
href: string | null;
7+
external: boolean;
8+
}
9+
10+
export const products: readonly Product[] = [
11+
{
12+
id: "decocms",
13+
label: "decocms",
14+
description: "AI agents & MCP control plane",
15+
href: null,
16+
external: false,
17+
},
18+
{
19+
id: "deco-cx",
20+
label: "deco.cx",
21+
description: "Storefront platform",
22+
href: "https://docs.deco.cx",
23+
external: true,
24+
},
25+
];
26+
27+
export const CURRENT_PRODUCT_ID = "decocms";

apps/docs/client/src/layouts/DocsLayout.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import BaseHead from "../components/ui/BaseHead.astro";
33
import Footer from "../components/ui/Footer.astro";
44
import Sidebar from "../components/ui/Sidebar.astro";
55
import TableOfContents from "../components/ui/TableOfContents.astro";
6-
import { Logo } from "../components/atoms/Logo";
76
import { Icon } from "../components/atoms/Icon";
87
import { LanguageSelector } from "../components/ui/LanguageSelector";
8+
import { ProductSwitcher } from "../components/ui/ProductSwitcher";
99
import { ThemeToggle } from "../components/ui/ThemeToggle";
1010
import { getCollection } from "astro:content";
1111
import { siteConfig } from "../config/site";
@@ -286,7 +286,7 @@ const editUrl = doc
286286
>
287287
<Icon name="Menu" size={24} />
288288
</button>
289-
<Logo width={67} height={28} client:load />
289+
<ProductSwitcher client:load />
290290
</div>
291291
<div class="flex items-center gap-2">
292292
<LanguageSelector client:load locale={locale} className="w-32" />

0 commit comments

Comments
 (0)