// ProfileMenu — circular avatar trigger + Radix-backed DropdownMenu // with theme + language switcher, app-specific links, and an optional // sign-out action. Dropped into every Voltro-Cloud sibling app's // header (dashboard / marketplace / docs) as the rightmost element. // // Architecture: // • Built on the kit's primitive (Radix wrapper) — // get focus management, arrow-key nav, portal, escape, click- // outside, click-outside-on-overlay-elements for free. // • The kit OWNS theme + language switcher rendering. Theme writes // the `voltro:theme` cookie (matches `themeBootScript`) and // toggles `` so the change is instant + survives // reload via the boot script. // • Language writes the `voltro:locale` cookie, then reloads (the SSR // re-runs in the new locale) unless `onLanguageChange` returns // `false`. Hidden when `languages` is omitted. // • Every user-facing string is a prop (`labels`) with English // defaults — the consuming app's catalog can localize the menu's // own chrome (it IS the language switcher, after all). // • Identity is optional. When absent the avatar shows a neutral // glyph and the menu omits the identity header — useful on public // pages (marketplace / docs) where the visitor is anonymous. // • Sign-out is a real form-POST inside a `DropdownMenuItem`'s // `onSelect` handler so the browser handles redirects + Set-Cookie. // • Sign-in is a regular menu item that navigates. import { useCallback, useEffect, useState, type ReactNode } from 'react' import { Button } from '../primitives/button' import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, } from '../primitives/dropdownMenu' import { ToggleGroup, ToggleGroupItem } from '../primitives/toggleGroup' import { THEME_COOKIE, LOCALE_COOKIE, getCookie, setCookie, deleteCookie, applyTheme, type ThemePreference, } from '../cookies' const readStoredTheme = (): ThemePreference => { const v = getCookie(THEME_COOKIE) if (v === 'dark' || v === 'light' || v === 'system') return v return 'system' } const persistTheme = (choice: ThemePreference): void => { // 'system' deletes the cookie so the next request falls back to // server default / matchMedia (matches themeBootScript semantics). if (choice === 'system') deleteCookie(THEME_COOKIE) else setCookie(THEME_COOKIE, choice) applyTheme(choice) } // ---- Public types ---- export interface ProfileMenuLink { readonly key: string readonly label: string readonly href: string readonly icon?: ReactNode readonly external?: boolean } export interface ProfileMenuLanguage { readonly code: string readonly label: string } export interface ProfileMenuProps { readonly identity?: { readonly name: string readonly sublabel?: string } readonly links?: ReadonlyArray readonly languages?: ReadonlyArray /** Override the cookie-derived language (e.g. when the consumer * knows the SSR-resolved value before the cookie round-trips). */ readonly currentLanguage?: string /** Optional hook that fires AFTER the cookie is written. Default * behaviour: `window.location.reload()` so the SSR re-runs in the * new locale. Return `false` to suppress the reload (useful when * a client-side i18n runtime can hot-swap). */ readonly onLanguageChange?: (code: string) => boolean | void /** URL the logout form posts to. Triggers a full page nav. For * apps that have a soft-logout RPC + in-place UI swap (no reload), * pass `onLogout` instead — it takes precedence over `logoutUrl`. */ readonly logoutUrl?: string /** Callback-style logout — fires when the user picks "Log out". When * supplied, replaces the form-post-to-`logoutUrl` flow with a pure * JS handler. Use this for soft-logout flows that re-issue * subscriptions on the same WebSocket without navigating away. */ readonly onLogout?: () => void readonly signInUrl?: string /** User-facing strings — pass values from the consuming app's catalog * to localize the menu's own chrome. Defaults are English. */ readonly labels?: ProfileMenuLabels } export interface ProfileMenuLabels { /** Section label for the theme switch. Default `'Theme'`. */ readonly theme?: string /** Section label for the language switch. Default `'Language'`. */ readonly language?: string /** Sign-out menu item. Default `'Sign out'`. */ readonly signOut?: string /** Sign-in menu item. Default `'Sign in'`. */ readonly signIn?: string /** `aria-label` of the avatar trigger. Default `'Open profile menu'`. */ readonly openMenu?: string /** Labels of the three theme options. Defaults `'System'`/`'Light'`/`'Dark'`. */ readonly themes?: { readonly system?: string; readonly light?: string; readonly dark?: string } } // ---- Avatar glyph ---- // // Both branches use `text-current` so the glyph inherits whatever // color the wrapping {identity ? ( <>
{identity.name}
{identity.sublabel ? (
{identity.sublabel}
) : null}
) : null} {hasLinks ? ( <> {links.map((link) => ( navigateTo(link.href, link.external)} > {link.icon ?? null} {link.label} ))} ) : null} {/* Theme + language rendered as inline s — same * shadcn primitive used elsewhere for chip-radio rows. NOT * DropdownMenuItems: keyboard nav steps over each ToggleGroup * as one focusable region, then ←/→ inside Radix's * RovingFocusGroup picks an option. */}
{labels?.theme ?? 'Theme'} { if (v) onThemeChange(v) }} > {THEME_VALUES.map((value) => ( {themeLabel(value)} ))}
{hasLanguages ? (
{labels?.language ?? 'Language'} { if (v) onLocaleSelect(v) }} > {languages.map((l) => ( {l.label} ))}
) : null} {hasFooterAction ? : null} {identity && (logoutUrl || onLogout) ? ( {labels?.signOut ?? 'Sign out'} ) : !identity && signInUrl ? ( navigateTo(signInUrl)}> {labels?.signIn ?? 'Sign in'} ) : null}
) }