"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 ? (
{entry.scope}
) : null}
{current ? (
) : null}
)
}
/**
* A product you do not own: selecting it opens what it is, not the thing itself.
*
* These rows used to switch, which dropped you inside an unfamiliar product with
* no explanation of what it did or how to get it — the answer to "what is
* Accreditation" is a page that already exists at `/home/`, and this is
* the only place in the shell that asks the question. The arrow says the row
* leaves the menu rather than changing what you are in, since every other row
* here does the opposite.
*/
function CatalogProductRow({ entry }: { entry: SwitcherProductEntry }) {
// Custom products have no catalog entry, and no stage to report either.
const stage = entry.customIndex === undefined ? findCatalogEntry(entry.id)?.stage : undefined
return (
{stage ? : null}
)
}
/**
* Group heading.
*
* Title only. Each group used to carry a line explaining itself ("Included in
* your plan.", "Maintained once."), which is three sentences of chrome in a
* menu whose whole job is to be scanned, restating what the heading and the
* grouping already say.
*/
function GroupLabel({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
/**
* People, Courses, and Personnel.
*
* These are not products and never appear in either group above, but they are
* the records every product in those groups reads from, and the switcher is
* where someone goes when they want to be somewhere else. Leaving them out of
* the one menu that lists every destination meant the only way to reach them
* was to already be on the home page.
*
* Same heading and same order as the home page, so the two surfaces tell one
* story.
*/
/**
* Icon for the two rows below the last separator.
*
* No tile, because these are not products and should not look like one, but the
* same footprint a tile takes, so every label in the menu starts on the same
* vertical line instead of the last two stepping left.
*/
function FooterIcon({ icon }: { icon: string }) {
return (
)
}
function DirectoryRows() {
const records = React.useMemo(() => adminObjectSummaries(), [])
return (
<>
Directory
{records.map(object => (
{object.label}
{object.total}
))}
>
)
}
export function ProductSwitcherMenuItems() {
const { product, customProducts, activeCustomIndex, hiddenProducts } = useProduct()
const switchProduct = useProductSwitch()
const products = React.useMemo(
() => expandSwitcherProducts(customProducts, hiddenProducts),
[customProducts, hiddenProducts],
)
const owned = React.useMemo(() => products.filter(isEntitledEntry), [products])
const more = React.useMemo(() => products.filter(p => !isEntitledEntry(p)), [products])
const isCurrentProduct = React.useCallback(
(entry: SwitcherProductEntry) =>
entry.id === product &&
(entry.customIndex === undefined || entry.customIndex === activeCustomIndex),
[activeCustomIndex, product],
)
return (
<>
Your App
{owned.map(entry => (
switchProduct(entry.id, entry.customIndex)}
/>
))}
{/* Administrator closes "Your App" rather than sitting in the footer
beside All apps. It is an app this person opens, not a utility link:
every comparable launcher (Okta, Salesforce, Microsoft 365) lists the
admin console with the apps. Role-gated, so a member never sees a door
they cannot walk through. */}
{isWorkspaceAdmin() ? (
{/* The Directory rows' neutral tile, not the footer's bare glyph:
sitting between two branded products, an untiled row read as an
afterthought. Neutral rather than branded because the console is
not a product with an identity of its own. */}
Administrator
{product === "exxat-admin" ? (
) : null}
) : null}
{more.length > 0 ? (
<>
More from Exxat
{more.map(entry => (
))}
>
) : null}
All apps
>
)
}