# Dashboard UI conventions The dashboard is Next.js App Router (`src/app`), with shadcn/Radix primitives in `src/components/ui`, Tailwind, `next-intl` for i18n, and `react-hook-form` + zod for forms. ## Translations (always) - Every user-facing string goes through `t()`: labels, descriptions, placeholders, button text, toasts, tab titles, table headers, empty states, zod error messages shown to users, and `aria-label`s. - Client components: `const t = useTranslations();` from `next-intl`. - Server components / layouts: `const t = await getTranslations();` from `next-intl/server`. - Interpolation: `t("resourceSetting", { resourceName: resource.name })`, with `{resourceName}` in the message string. - Add new keys **only** to `messages/en-US.json`. **Never** add or edit translations in the other `messages/*.json` files; Crowdin generates them. - Before adding a key, search `messages/en-US.json` for an existing one (`general`, `name`, `saveSettings`, `learnMore`, `cancel`, `close`, `delete`...). Name new keys camelCase with a feature prefix (e.g. `orgGeneralSettingsDescription`, `domainsBannerTitle`). ## Modals: always Credenza Use `Credenza` from `@app/components/Credenza` for every modal or dialog. It renders a dialog on desktop and a bottom drawer on mobile. Don't import `Dialog`, `Sheet`, `Drawer` or `AlertDialog` from `components/ui` directly. ```tsx {t("...")} {t("...")} {/* form with id="my-form" */} ``` Examples: `src/components/CreateSiteProvisioningKeyCredenza.tsx`, `src/components/CustomDnsRecordCredenza.tsx`. For destructive confirmations, use `ConfirmDeleteDialog` (a Credenza where the user types the name to confirm). ## Pages vs modals - **Use pages** for anything that's an object with more than a few settings (resources, sites, clients, providers, policies, alert rules, ...): - list page: `src/app/[orgId]/settings//page.tsx` (table) - create page: `...//create/page.tsx` - detail page: `...//[id]/layout.tsx` + one folder per tab - **Use a Credenza** only for small, quick forms (a couple of fields), pickers, and confirmations. ## Info for the user: DismissableBanner, not Alert To explain a feature or give the user context on a page, use `DismissableBanner` (`src/components/DismissableBanner.tsx`), not an ``. Wrap it in a small `Banner.tsx` component: ```tsx export const DomainsBanner = () => { const t = useTranslations(); return ( } description={t("domainsBannerDescription")} /> ); }; ``` Render it under the `SettingsSectionTitle` of the page (see `src/app/[orgId]/settings/domains/page.tsx`). Bump `version` to show it again after changing the content. Existing banners (copy one): `src/components/*Banner.tsx`, e.g. `DomainsBanner`, `SitesBanner`, `PublicResourcesBanner`, `PrivateResourcesBanner`, `UsersBanner`, `ShareLinksBanner`. Paywalled features use ``, which handles its own visibility. ## Tabs: splitting an object's settings across pages Detail and settings areas are split into **route-based tabs**. Each tab is its own route segment with its own `page.tsx`, and a shared `layout.tsx` renders the header and `HorizontalTabs`. Example: org settings (`src/app/[orgId]/settings/general/`): ``` general/ layout.tsx server component: fetch org, build navItems, render tabs page.tsx "General" tab (exact: true, because it is the index route) security/page.tsx "Security" tab logs/page.tsx "Logs" tab auth-page/page.tsx "Auth Page" tab (hidden when enterprise features are disabled) ``` `layout.tsx` pattern: ```tsx export default async function Layout({ children, params }) { const { orgId } = await params; const t = await getTranslations(); // fetch the object server-side (internal + authCookieHeader, or getCachedOrg) // redirect(...) on failure const navItems: TabItem[] = [ { title: t("general"), href: `/{orgId}/settings/general`, exact: true }, { title: t("security"), href: `/{orgId}/settings/general/security` }, ...(!env.flags.disableEnterpriseFeatures ? [{ title: t("authPage"), href: `/{orgId}/settings/general/auth-page` }] : []) ]; return (
{/* summary card for the object */} {children}
); } ``` Notes: - `href`s use placeholders (`{orgId}`, `{niceId}`, `{resourceId}`, `{clientId}`, `{userId}`, `{domainId}`, `{apiKeyId}`, `{providerId}`, `{remoteExitNodeId}`) that `HorizontalTabs` fills in from route params. If you add a new param name, add it to `hydrateHref` in `src/components/HorizontalTabs.tsx`. - Use `exact: true` on a tab whose href is a prefix of the other tabs (the index tab). - Build tabs conditionally from object state, like the resource layout shows mode-specific tabs: `src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx`. - When there's no index tab, `[id]/page.tsx` just `redirect`s to the first tab (`.../[niceId]/page.tsx` → `/general`). - The layout puts the object into a Provider (`OrgProvider`, `ResourceProvider`, `SiteResourceProvider`, ...). Tab pages are client components that read it with the matching hook (`useOrgContext`, `useResourceContext`, `useSiteResourceContext`) and call `updateX(...)` on the context after saving so the header card stays current. - `showProfessional` on a `TabItem` marks paid tabs. - For tabs inside a single page or a Credenza (no routing), use `` with one child per tab. ### Inside a tab page Each tab page is a `SettingsContainer` holding one or more `SettingsSection`s. Each section is a separate concern with its own form and its own Save button in the footer. See `src/app/[orgId]/settings/general/page.tsx`: a "General" section form plus a separate danger-zone delete section. ```tsx {t("general")} {t("...")}
{/* FormField */} {/* SwitchInput */}
``` - `SettingsFormGrid` is a 4-column grid. `SettingsFormCell span` is `"quarter" | "half" | "full"`. - Switches use `SwitchInput` (`@app/components/SwitchInput`). - The submit button sits in the footer, outside the `
`, and links to it with `form=""`. ## Forms - `useForm({ resolver: zodResolver(schema), defaultValues })` with shadcn `Form`/`FormField`/`FormItem`/`FormLabel`/`FormControl`/`FormDescription`/`FormMessage`. - If the schema has user-facing error messages, build it inside the component with `t` (e.g. `createGeneralFormSchema(t)` in `src/lib/privateResourceForm.ts`). - Saving: `useActionState(performSave, null)` + `startTransition(() => formAction())` (see general/page.tsx), or a `useTransition`. Give the button `loading` and keep its text the same while loading. - Results: `toast({ title: t("..."), description: t("...") })`. On error: `toast({ variant: "destructive", title: t("..."), description: formatAxiosError(e, t("...")) })`. Call `router.refresh()` after saving when server components show the value. ## Paywalls and build gating in the UI - `useEnvContext().env.flags.disableEnterpriseFeatures`: hide enterprise UI. - `usePaidStatus().isPaidUser(tierMatrix[TierFeature.X])`: disable controls for unpaid orgs, and pair it with ``. - `build` from `@server/build` for oss/enterprise/saas differences. ## Navigation Sidebar entries live in `src/app/navigation.tsx` (`orgNavSections`, `adminNavSections`, `commandBarNavSections`). Add a new top-level settings page there, with a translated title.