"use client" /** * The one scope (school > program / brand > site > location) picker menu. * * This body was previously copy-pasted into `UtilityBarSchoolSwitcher` and the * sidebar's `TeamSwitcher`, each with its own `useState` — so the two chrome * surfaces could show different programs at the same time. Both now render * this component over shared `useActiveScope` state, which the products home * writes to before the user has even entered the product. * * Only the *trigger* differs per surface: an avatar circle in the utility bar, * a full row in the sidebar, a card row on the products home. The menu itself * is identical everywhere, so it lives here once. * * Whether there is a menu at all is also decided here, once, as `fixed`: a * session that does not choose its scope (a student, who is enrolled in one * program at one school) gets the names and no trigger. */ import * as React from "react" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu" import type { Product } from "@/contexts/product-context" import { useActiveScope, type ScopeStatus } from "@/hooks/use-active-scope" import type { ScopeChild, ScopeConfig, ScopeParent } from "@/lib/scope-switcher" import { cn } from "@/lib/utils" import { isScopeFixed } from "@/lib/workspace-role" export interface ScopeSwitcherBag { config: ScopeConfig /** * `"open"` means both names below are set. `"choose"` means this product is * licensed for scopes but the session has not picked one here, so every trigger * reads the prompt and the menu asks. `"none"` means nothing is licensed. */ status: ScopeStatus parent: ScopeParent | null child: ScopeChild | null /** Trigger text: the child's name, or the prompt when there is nothing to name. */ label: string /** Parent's name, or the product-level prompt. Never empty. */ parentLabel: string suggestedParent: ScopeParent | null selectScope: (parent: ScopeParent, child: ScopeChild) => void subView: "main" | "parents" /** Pass to `DropdownMenu` so the menu reopens on the main view. */ onOpenChange: (open: boolean) => void showParents: () => void showMain: () => void selectParent: (parent: ScopeParent) => void selectChild: (child: ScopeChild) => void /** * True when this session does not choose its scope (`isScopeFixed`), so every * trigger renders the two names without a menu behind them. Decided here rather * than at each trigger: the three surfaces already share this bag, and a * predicate copied three times is a predicate one surface forgets. */ fixed: boolean /** `" · . "` — for `aria-label`. */ ariaLabel: string } /** Shared state + handlers for any trigger that opens `ScopeSwitcherMenuBody`. */ export function useScopeSwitcher(product: Product, customIndex?: number): ScopeSwitcherBag { const { config, status, parent, child, suggestedParent, selectParent, selectChild, selectScope, } = useActiveScope(product, customIndex) const [subView, setSubView] = React.useState<"main" | "parents">("main") const fixed = isScopeFixed() const named = parent !== null && child !== null return { config, status, parent, child, label: child?.name ?? config.choosePrompt, parentLabel: parent?.name ?? config.choosePrompt, suggestedParent, selectScope, subView, onOpenChange: open => { if (!open) setSubView("main") }, showParents: () => setSubView("parents"), showMain: () => setSubView("main"), selectParent: next => { selectParent(next) setSubView("main") }, selectChild, fixed, // Unresolved has no two names to read out, so the label carries the ask // instead. Keeping the same slot means no trigger needs its own branch. ariaLabel: named ? `${parent.name} · ${child.name}. ${fixed ? config.fixedAriaSuffix : config.ariaSuffix}` : config.choosePrompt, } } /** Menu contents. Render inside a `DropdownMenuContent`. */ export function ScopeSwitcherMenuBody({ scope }: { scope: ScopeSwitcherBag }) { const { config, parent, child, subView, showParents, showMain } = scope // Nothing chosen here yet: the same question the chooser page asks, in menu // items, so the chrome is a second way to answer it rather than dead until // the page is used. Every row names both halves because there is no current // school to hang a bare program on. if (parent === null || child === null) { if (scope.status === "none") { return ( {`No ${config.childNoun.toLowerCase()}s for your account`} ) } return ( <> {config.choosePrompt} {config.parents.map(option => ( {config.parents.length > 1 ? ( {option.name} ) : null} {config.childrenOf(option).map(childOption => ( scope.selectScope(option, childOption)} className="items-start py-2" >