"use client" /** * Body of the product switcher menu — shared by the sidebar trigger * (`ProductLogoButton` in `sidebar/app-sidebar.tsx`) and the utility bar * trigger (`UtilityBarProductSwitcher`). * * One list used to run top to bottom with a single "Switch product" label on * it, which said nothing about the difference between the products this * workspace pays for and the six it does not. The reader had to already know. * The menu now splits on entitlement, in the same groups and the same words the * products home uses, so the two surfaces tell one story. * * Entitlement decides where a row goes *and* what it does. Owned products * switch. Unowned ones open their marketing page, because switching into a * product nobody here has bought answers a question they did not ask. * * Both triggers render this so the groups, order, and copy cannot drift apart. */ import * as React from "react" import { Link } from "react-router" import { ProductSwitcherMenuRowLabel } from "@/components/exxat-product-logo" import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu" import { StatusBadge } from "@/components/ui/status-badge" import { useProduct } from "@/contexts/product-context" import { useProductSwitch } from "@/contexts/product-route-sync" import { adminObjectSummaries } from "@/lib/mock/admin-directory" import { findCatalogEntry, isProductEntitled } from "@/lib/mock/product-catalog" import { NEUTRAL_TILE_CLASS } from "@/lib/product-glyph" import { productHomeSlug } from "@/lib/product-home" import { expandSwitcherProducts, type SwitcherProductEntry, } from "@/lib/product-switcher-catalog" import { cn } from "@/lib/utils" import { isWorkspaceAdmin } from "@/lib/workspace-role" import type { CustomProductBrand } from "@/stores/app-store" /** * Wider than `SHELL_IDENTITY_MENU_SURFACE_CLASS`, which this menu used to share * with the scope switcher. * * That width is sized for a menu whose rows are a name and nothing else. These * rows carry a glyph tile, a product name, a stage badge, and a count or a * tick, and at 18rem the two longest names — "Surveys & Course Evaluations" and * "Student & Program Success" — wrapped to a second line to make room for the * badge beside them. Wrapping is the right fallback and it stays as one, but it * should not be the resting state for a quarter of the list. * * The cap still keeps the menu inside the viewport at Windows 125–150% display * scaling, where the CSS viewport shrinks without `visualViewport.scale` * changing. */ export const PRODUCT_SWITCHER_MENU_SURFACE_CLASS = "w-84 max-w-[min(100vw-2rem,21rem)]" as const /** Custom products are authored by this workspace, so they are owned by definition. */ function isEntitledEntry(entry: SwitcherProductEntry): boolean { if (entry.customIndex !== undefined) return true return isProductEntitled(entry.id) } function entryKey(entry: SwitcherProductEntry): string { return entry.customIndex !== undefined ? `${entry.id}-${entry.customIndex}` : entry.id } /** * A product you own: selecting it switches you into it. * * Scope and stage sit right of the name; the tick is the only thing pinned to * the far edge, so "which one am I in" is answered by looking down one column * rather than reading each row. */ function OwnedProductRow({ entry, current, previewCustomBrand, onSelect, }: { entry: SwitcherProductEntry current: boolean previewCustomBrand?: CustomProductBrand onSelect: () => void }) { return ( {entry.scope ? ( ) : null} {current ? (