Files
zeekay cb6db9e659 design: every page and component outside the landing and beta paths is at zero lint
Inline style objects became gui props (Box for a div, View or Text rendering the
element for the rest, since Box styles only a div), raw colours and sizes became
the design's tokens, the brand swatches read @hanzo/design's own values, and the
project and creation hues moved to palette.json, which is data. The newsletter's
subscribe button is the standard filled Action. connect-steps.tsx, which nothing
imports, is deleted with the brand constants nobody read.

Pages compared before and after at 1440x900 and 390x844: blockchain, brand,
directory, projects, settings, unsubscribe identical; founder 0.03% of pixels;
buying specialist's title snaps from 28 to the ramp's 26px and the newsletter's
button is now filled.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 03:49:42 -07:00

647 lines
23 KiB
TypeScript

'use client'
/**
* The settings area's frame: a column of places beside the page, both panels on
* the app's ground, and the one place that asks who is reading and in which org.
*
* NOTHING PRODUCT-SHAPED IS IN THE EXPORT. Until hydration and until IAM has
* answered, the frame is the veil the rooms wear, so the static file holds no
* column, no form and no name — a file cannot know who fetched it. A reader with
* no session is sent to sign in and back (lib/auth/destination.ts `enter`).
*
* WHO MAY DO WHAT IS IAM'S ANSWER, READ HERE ONCE. The role is the one IAM signed
* into the token for the acting org (lib/auth/session.ts `role`), so "admin" is
* an owner or admin of THIS org and nothing else (HIP-0527 I15): it is never read
* from another org, from the account, or from being a SuperAdmin. Pages draw the
* controls an owner or admin gets from `admin`, and every write is still IAM's to
* refuse — the frame hides a control, it does not grant one.
*/
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ArrowLeft, BookOpen, ChevronsUpDown, CircleHelp, Coins, Menu, MessagesSquare, Search, Settings, X } from 'lucide-react'
import { useIam } from '@hanzo/iam/react'
import { useI18n } from '@hanzo/i18n/react'
import { useSignOut } from '@hanzo/app/lib/signout'
import {
Avatar,
AvatarFallback,
AvatarImage,
Box,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
Kbd,
Text,
View,
XStack,
YStack,
} from '@hanzo/ui'
import { useAccount } from '@hanzo/app/lib/account'
import { useHydrated } from '@hanzo/app/lib/hydrated'
import { useRoute } from '@/components/shell/route'
import { enter } from '@hanzo/app/lib/destination'
import { ORG, named, org as acting, orgs as memberships, pick, role as roleIn } from '@hanzo/app/lib/session'
import { readOrg, say, cents, type OrgRow } from '@hanzo/app/lib/org'
import { useTier, type Tier } from '@hanzo/app/lib/tier'
import { payPage } from '@hanzo/app/lib/pay'
import { PLACES, find, here, type Place } from './nav'
import { Line } from './kit'
import { floor, pane } from '@hanzo/app/ground'
import { WindowControls, dragWindow, toggleWindowMaximize } from '@/components/shell/WindowControls'
/** What every page reads about the reader and the org they are configuring. */
export interface Standing {
/** The acting org's handle, or null where the reader has not chosen one. */
org: string | null
/** The reader's role in it as IAM signed it: owner | admin | member. */
role: string | null
/** Owner or admin of THIS org. */
admin: boolean
/** The org row, once IAM answers it. */
row: OrgRow | null
/** Why the row could not be read. */
rowWrong: string | null
/** An org of one; null until the row says. */
personal: boolean | null
reloadRow: () => void
tier: Tier | null
tierWrong: unknown
reloadTier: () => void
/** The reader, as IAM names them. */
name: string
email: string
/** Their picture, where IAM holds one. */
avatar: string
/** Whether Add funds is theirs to press: an owner or admin, or a wallet of their own. */
funds: boolean
}
const Ctx = createContext<Standing | null>(null)
export function useStanding(): Standing {
const s = useContext(Ctx)
if (!s) throw new Error('useStanding is read under the settings Frame')
return s
}
/** A role, as a label. */
export const said = (role: string | null): string => (role ? role[0].toUpperCase() + role.slice(1) : 'No role')
/** A pane's fill: the frame's own where the app's material defines it, the ladder's first rung where not. */
const FILL = 'var(--pane-fill, var(--surface-1))'
/** The veil sits ten above the room's own modals. gui carries a `calc()` to the page unchanged; its types only name
* custom properties. */
const SHEET = 'calc(var(--z-modal) + 10)' as `var(--${string})`
/** The rooms' veil: the one element the export holds, identical before and after hydration. */
function Veil() {
return <View aria-hidden position="fixed" inset={0} z={SHEET} bg="var(--background)" />
}
export function Frame({ children }: { children: ReactNode }) {
const hydrated = useHydrated()
const { isAuthenticated, isLoading } = useIam()
useEffect(() => {
if (hydrated && !isLoading && !isAuthenticated) enter()
}, [hydrated, isLoading, isAuthenticated])
if (!hydrated || isLoading || !isAuthenticated) return <Veil />
return <Signed>{children}</Signed>
}
/** The acting org, kept current when it is switched here or in another tab. */
function useActing(token: string | undefined): string | null {
const [current, setCurrent] = useState<string | null>(() => acting())
useEffect(() => {
setCurrent(acting())
const on = () => setCurrent(acting())
window.addEventListener(ORG, on)
window.addEventListener('storage', on)
return () => {
window.removeEventListener(ORG, on)
window.removeEventListener('storage', on)
}
}, [token])
return current
}
function Signed({ children }: { children: ReactNode }) {
const { accessToken } = useIam()
const { user } = useAccount()
const org = useActing(accessToken ?? undefined)
const role = useMemo(() => roleIn(org), [org, accessToken]) // eslint-disable-line react-hooks/exhaustive-deps
const admin = role === 'owner' || role === 'admin'
const [held, setHeld] = useState<{ org: string; row: OrgRow | null; wrong: string | null } | null>(null)
const [nonce, setNonce] = useState(0)
useEffect(() => {
if (!org) return
let live = true
readOrg(org)
.then((row) => live && setHeld({ org, row, wrong: null }))
.catch((e: unknown) => live && setHeld({ org, row: null, wrong: say(e) }))
return () => {
live = false
}
}, [org, nonce])
const mine = held && held.org === org ? held : null
const reloadRow = useCallback(() => setNonce((n) => n + 1), [])
const { tier, wrong: tierWrong, reload: reloadTier } = useTier(Boolean(org), org)
const claimed = useMemo(() => named(), [accessToken]) // eslint-disable-line react-hooks/exhaustive-deps
const account = tier?.balance.account
const standing: Standing = {
org,
role,
admin,
row: mine?.row ?? null,
rowWrong: mine?.wrong ?? null,
personal: mine?.row ? Boolean(mine.row.isPersonal) : null,
reloadRow,
tier,
tierWrong,
reloadTier,
name: user?.name || claimed?.name || '',
email: user?.email || claimed?.email || '',
avatar: user?.avatar || '',
funds: admin || Boolean(account && org && account !== org),
}
const route = useRoute()
const place = here(route)
const [sheet, setSheet] = useState(false)
const seek = useRef<HTMLInputElement | null>(null)
// ⌘K / Ctrl+K takes the reader to the search, on a phone by opening the column.
useEffect(() => {
const key = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
if (window.matchMedia('(max-width: 767.98px)').matches) setSheet(true)
requestAnimationFrame(() => seek.current?.focus())
}
if (e.key === 'Escape') setSheet(false)
}
window.addEventListener('keydown', key)
return () => window.removeEventListener('keydown', key)
}, [])
const needsOrg = Boolean(place?.org) && !org
const isDesktop =
typeof window !== 'undefined' &&
Boolean(
(window as unknown as { __TAURI__?: unknown; __TAURI_INTERNALS__?: unknown }).__TAURI__ ||
(window as unknown as { __TAURI__?: unknown; __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__,
)
return (
<Ctx.Provider value={standing}>
<YStack {...floor} height="100%" overflow="hidden">
{isDesktop ? (
<XStack
data-slot="title-bar"
data-tauri-drag-region="deep"
onMouseDown={dragWindow}
onDoubleClick={toggleWindowMaximize}
height={38}
shrink={0}
items="center"
gap="$2"
px="$2"
>
<Link href="/" aria-label="Back to app">
<XStack items="center" justify="center" width={28} height={28} rounded="$3" hoverStyle={{ bg: '$hover' }}>
<ArrowLeft size={16} aria-hidden data-i18n-mirror />
</XStack>
</Link>
<Text fontSize="$2" fontWeight="500" color="$soft" numberOfLines={1} data-tauri-drag-region="deep">
{place?.label ?? 'Settings'}
</Text>
<View flex={1} self="stretch" data-tauri-drag-region="deep" />
<WindowControls />
</XStack>
) : null}
{/* The app's frame: panes floating on the lit floor, one gutter round the
window and between them — the material the app at `/` wears
(components/workspace/ground.ts). */}
<XStack data-slot="frame" flex={1} minH={0} gap="$2" p="$2" pt={isDesktop ? 0 : '$2'} $lg={{ gap: '$3', p: '$3', pt: isDesktop ? 0 : '$3' }}>
<YStack
render="nav"
aria-label="Settings"
{...pane(FILL)}
display="none"
$md={{ display: 'flex' }}
width={280}
shrink={0}
overflow="hidden"
>
<Column seek={seek} />
</YStack>
<YStack render="main" {...pane(FILL)} flex={1} minW={0} overflow="hidden">
<XStack
$md={{ display: 'none' }}
items="center"
gap="$2"
px="$3"
py="$2"
borderBottomWidth={1}
borderColor="$edge"
>
<Link href="/" aria-label="Back to app">
<XStack items="center" justify="center" width={44} height={44} rounded="$4" hoverStyle={{ bg: '$hover' }}>
<ArrowLeft size={18} aria-hidden data-i18n-mirror />
</XStack>
</Link>
<Text flex={1} fontSize="$4" fontWeight="500" color="$ink" numberOfLines={1}>
{place?.label ?? 'Settings'}
</Text>
<Box
render="button"
aria-label="Settings menu"
aria-expanded={sheet}
onClick={() => setSheet(true)}
width={44}
height={44}
rounded="$4"
items="center"
justify="center"
bg="transparent"
borderWidth={0}
cursor="pointer"
hoverStyle={{ bg: '$hover' }}
>
<Menu size={18} aria-hidden color="var(--foreground)" />
</Box>
</XStack>
<YStack flex={1} minH={0} overflow="scroll">
<YStack width="100%" maxW={760} mx="auto" px="$4" py="$6" $md={{ px: '$7', py: '$8' }} gap="$7">
{needsOrg ? <Choose /> : children}
</YStack>
</YStack>
</YStack>
{sheet ? (
<YStack
role="dialog"
aria-modal
aria-label="Settings"
position="fixed"
t={0}
l={0}
r={0}
b={0}
bg="$background"
p="$2"
z="var(--z-modal)"
>
<YStack {...pane(FILL)} flex={1} overflow="hidden">
<XStack justify="flex-end" px="$2" pt="$2">
<Box
render="button"
aria-label="Close settings menu"
onClick={() => setSheet(false)}
width={44}
height={44}
rounded="$4"
items="center"
justify="center"
bg="transparent"
borderWidth={0}
cursor="pointer"
hoverStyle={{ bg: '$hover' }}
>
<X size={18} aria-hidden color="var(--foreground)" />
</Box>
</XStack>
{/* A place chosen here closes the column it was chosen from. */}
<Column seek={seek} onGo={() => setSheet(false)} />
</YStack>
</YStack>
) : null}
</XStack>
</YStack>
</Ctx.Provider>
)
}
/** One row of the column: a place, with its mark, marked when it is open. */
function Row({ place, active, onGo }: { place: Place; active: boolean; onGo?: () => void }) {
return (
<Link href={place.href} aria-current={active ? 'page' : undefined} onClick={onGo}>
<XStack
items="center"
gap="$3"
px="$3"
minH={40}
rounded="$4"
bg={active ? '$raised' : 'transparent'}
hoverStyle={{ bg: active ? '$raised' : '$hover' }}
>
<place.Icon size={16} aria-hidden />
<Text fontSize="$3" color={active ? '$ink' : '$soft'} fontWeight={active ? '500' : '400'} numberOfLines={1}>
{place.label}
</Text>
</XStack>
</Link>
)
}
/** The column: back, search, the places, and the reader at its foot. */
function Column({ seek, onGo }: { seek: React.RefObject<HTMLInputElement | null>; onGo?: () => void }) {
const router = useRouter()
const route = useRoute()
const { personal } = useStanding()
const [q, setQ] = useState('')
const reachable = PLACES.filter((p) => !(p.team && personal === true))
const shown = find(reachable, q)
const mine = shown.filter((p) => !p.org)
const theirs = shown.filter((p) => p.org)
const open = here(route)
const { dir } = useI18n()
// The column draws only after hydration, so the platform is known here.
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)
return (
<YStack flex={1} minH={0}>
<YStack flex={1} minH={0} overflow="scroll" p="$3" gap="$1">
<Link href="/">
<XStack items="center" gap="$3" px="$3" minH={40} rounded="$4" hoverStyle={{ bg: '$hover' }}>
<ArrowLeft size={16} aria-hidden data-i18n-mirror />
<Text fontSize="$3" color="$ink">
Back to app
</Text>
</XStack>
</Link>
<XStack position="relative" items="center" my="$2">
<Input
ref={seek}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && shown[0]) {
router.push(shown[0].href)
setQ('')
onGo?.()
}
if (e.key === 'Escape') setQ('')
}}
placeholder="Search settings"
aria-label="Search settings"
width="100%"
// The mark stands at the field's start and the keys at its end.
// Input states its own physical gutters, which a logical padding
// would race in the cascade, so the two sides trade by direction.
{...(dir === 'rtl' ? { pl: '$9', pr: '$7' } : { pl: '$7', pr: '$9' })}
/>
{/* After the field, so each paints over it without a stacking order of its own. */}
<Box position="absolute" insetInlineStart="$3" pointerEvents="none" aria-hidden>
<Search size={15} color="var(--muted-foreground)" />
</Box>
<XStack position="absolute" insetInlineEnd="$2" gap="$1" pointerEvents="none" aria-hidden data-i18n-ltr>
<Kbd>{mac ? '⌘' : 'Ctrl'}</Kbd>
<Kbd>K</Kbd>
</XStack>
</XStack>
{mine.map((p) => (
<Row key={p.href} place={p} active={open?.href === p.href} onGo={onGo} />
))}
{theirs.length > 0 ? (
<Text
render={<Link href="/settings/organization" onClick={onGo} />}
display="inline"
textDecorationLine="none"
>
<XStack
items="center"
gap="$3"
px="$3"
minH={40}
mt="$2"
rounded="$4"
hoverStyle={{ bg: '$hover' }}
cursor="pointer"
>
<Settings size={16} aria-hidden color="var(--foreground)" />
<Text fontSize="$3" fontWeight="500" color="$ink">
Organization settings
</Text>
</XStack>
</Text>
) : null}
<YStack gap="$1" paddingInlineStart="$4">
{theirs.map((p) => (
<Row key={p.href} place={p} active={open?.href === p.href} onGo={onGo} />
))}
</YStack>
{shown.length === 0 ? (
<Box px="$3" py="$2">
<Line>Nothing in settings matches “{q.trim()}”.</Line>
</Box>
) : null}
</YStack>
<Foot />
</YStack>
)
}
/** The column's foot: docs, credits, and who is reading in which org. */
function Foot() {
const { tier, tierWrong, funds } = useStanding()
const balance = tier ? cents(tier.balance.effectiveAvailable, tier.balance.currency || 'usd') : tierWrong ? 'Unavailable' : '…'
return (
<YStack p="$3" gap="$1" borderTopWidth={1} borderColor="$edge">
<a href="https://docs.hanzo.ai" target="_blank" rel="noreferrer">
<XStack items="center" gap="$3" px="$3" minH={36} rounded="$4" hoverStyle={{ bg: '$hover' }}>
<BookOpen size={16} aria-hidden />
<Text fontSize="$3" color="$soft">
Documentation
</Text>
</XStack>
</a>
<Link href="/support">
<XStack items="center" gap="$3" px="$3" minH={36} rounded="$4" hoverStyle={{ bg: '$hover' }}>
<CircleHelp size={16} aria-hidden />
<Text fontSize="$3" color="$soft">
Help center
</Text>
</XStack>
</Link>
<Link href="/contact">
<XStack items="center" gap="$3" px="$3" minH={36} rounded="$4" hoverStyle={{ bg: '$hover' }}>
<MessagesSquare size={16} aria-hidden />
<Text fontSize="$3" color="$soft">
Contact us
</Text>
</XStack>
</Link>
<XStack items="center" gap="$2" px="$3" minH={36}>
<Coins size={16} aria-hidden />
<Text fontSize="$3" color="$soft" marginInlineStart="$1">
Credits
</Text>
<Text fontSize="$3" color="$ink" marginInlineStart="auto" numberOfLines={1}>
{balance}
</Text>
{funds ? (
<a href={payPage('/amount/card')}>
<Text fontSize="$3" color="$ink" textDecorationLine="underline" numberOfLines={1}>
Add funds
</Text>
</a>
) : null}
</XStack>
<Who />
</YStack>
)
}
/**
* The reader and the org they are acting in, and the menu that moves them: to
* another of their orgs, back to the app, or out.
*/
function Who() {
const { name, email, avatar, role, org, row } = useStanding()
const { organizations } = useAccount()
const logout = useSignOut()
const mine = memberships()
const label = (o: string) => (o === org && row?.displayName) || organizations.find((x) => x.id === o)?.name || o
return (
<DropdownMenu placement="top-start">
<DropdownMenuTrigger asChild>
<Box
render="button"
aria-label="Account and organization"
mt="$2"
p="$2"
rounded="$4"
bg="transparent"
borderWidth={0}
cursor="pointer"
hoverStyle={{ bg: '$hover' }}
width="100%"
>
<XStack items="center" gap="$3" width="100%">
<Avatar size={32}>
<AvatarImage src={avatar || undefined} />
<AvatarFallback>
<Text fontSize="$2" fontWeight="500" color="$ink">
{(name || email || '?')[0].toUpperCase()}
</Text>
</AvatarFallback>
</Avatar>
<YStack flex={1} minW={0} items="flex-start">
<Text fontSize="$3" fontWeight="500" color="$ink" numberOfLines={1}>
{name || email}
</Text>
<Text fontSize="$2" color="$soft" numberOfLines={1}>
{org ? `${said(role)} · ${label(org)}` : 'No organization chosen'}
</Text>
</YStack>
<ChevronsUpDown size={15} aria-hidden />
</XStack>
</Box>
</DropdownMenuTrigger>
<DropdownMenuContent minW={240}>
{mine.length > 0 ? <DropdownMenuLabel>Organizations</DropdownMenuLabel> : null}
{mine.map((o) => (
<DropdownMenuItem
key={o}
onSelect={() => {
if (o === org) return
pick(o)
}}
>
<XStack items="center" justify="space-between" gap="$3" width="100%">
<Text fontSize="$3" color="$ink" numberOfLines={1}>
{label(o)}
</Text>
<Text fontSize="$2" color="$soft">
{o === org ? 'Current' : said(roleIn(o))}
</Text>
</XStack>
</DropdownMenuItem>
))}
{mine.length > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem onSelect={() => window.location.assign('/')}>
<Text fontSize="$3" color="$ink">
Back to app
</Text>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void logout()}>
<Text fontSize="$3" color="$ink">
Sign out
</Text>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
/** A reader in several orgs who has not chosen one is asked which they mean. */
function Choose() {
const { organizations } = useAccount()
const mine = memberships()
if (mine.length === 0) {
return (
<YStack gap="$3">
<Text render="h1" fontSize="$8" fontWeight="500" color="$ink">
Organization settings
</Text>
<Line>You do not belong to an organization yet.</Line>
<Link href="/home">
<Text fontSize="$3" color="$ink" textDecorationLine="underline">
Create one
</Text>
</Link>
</YStack>
)
}
return (
<YStack gap="$4">
<Text render="h1" fontSize="$8" fontWeight="500" color="$ink">
Choose an organization
</Text>
<Line>Settings apply to one organization at a time.</Line>
<YStack gap="$2">
{mine.map((o) => (
<Box
key={o}
render="button"
onClick={() => pick(o)}
p="$3"
rounded="$4"
borderWidth={1}
borderColor="$edge"
bg="transparent"
cursor="pointer"
hoverStyle={{ bg: '$hover' }}
>
<XStack items="center" justify="space-between" gap="$3">
<Text fontSize="$3" color="$ink">
{organizations.find((x) => x.id === o)?.name || o}
</Text>
<Text fontSize="$2" color="$soft">
{said(roleIn(o))}
</Text>
</XStack>
</Box>
))}
</YStack>
</YStack>
)
}