PVR Tech Studio

Settings Hub

A unified, five-tab workspace settings area — Profile, Site Settings, Permissions, Payment, and Mail — sharing one scaffold: a cover-image profile hero with the hub tabs built in, a two-column body, and a sticky right rail.

11 min read
Updated August 13, 2026

Overview

The Settings hub is the product-style settings area a real SaaS app would have — five routes that read as one surface:

RoutePageWhat it demos
/settings/profileProfilePagePersonal info, social profiles, change-password form.
/settingsSiteSettingsPageWorkspace identity + logo upload, regional selects, appearance, feature toggles, danger zone.
/settings/permissionsPermissionsPageRole cards, an editable role×permission matrix, team-member list, invite modal.
/settings/paymentPaymentPagePlan hero with billing-cycle toggle, usage tiles, realistic credit-card visuals, billing history.
/settings/mailMailSettingsPageProvider segmented control, SMTP / API-key configuration, send-a-test-email panel.

Every tab renders inside the shared SettingsLayout scaffold: a PageHeader with a presentational "Save changes" action, the ProfileHeader banner (cover-image hero + avatar + identity, with the SettingsNav tabs integrated along its bottom edge), then a two-column body (lg:grid-cols-[1fr_20rem]) — the tab's Panels on the left and the shared, sticky SettingsAside rail (plan widget + help card) on the right.

This hub is deliberately presentational. Forms hold local useState (or uncontrolled defaultValues); "Save changes", invites, card-adds, and test-sends fire success toasts and nothing is written to localStorage or an API. The one real integration is the Appearance section of Site Settings, which drives the actual Light / Dark / System theme through useTheme().

Architecture & files

FileResponsibility
src/components/settings/SettingsLayout.tsxShared scaffold: PageHeader + size="xs" "Save changes" button (fires the settings:savedToast toast), ProfileHeader, and the two-column grid with the sticky (lg:sticky lg:top-32) aside.
src/components/settings/SettingsNav.tsxThe hub tabs — a bare horizontal pill row of five NavLinks (icon + nav:* label, overflow-x-auto; the /settings link uses end so it isn't active on subpages). Its card frame comes from ProfileHeader.
src/components/settings/ProfileHeader.tsxShared banner on every tab: a cover-image hero (default /photos/photo-2.jpg) with a dark scrim, change-cover + change-photo file inputs (data-URL, size-capped), ringed avatar with a bg-success presence dot, name + role chip + meta chips, three AnimatedNumber stats, and SettingsNav on the surface strip below.
src/components/settings/SettingsAside.tsxShared right rail: a plan widget (Pro badge, seats/storage UsageBars, "Manage billing" → /settings/payment) and a help card (Documentation → /faq, Contact support → /apps/chat, Keyboard shortcuts → dispatches the open-shortcuts window event).
src/pages/settings/ProfilePage.tsxPersonal information, social profiles, and password panels (Update password → toast).
src/pages/settings/SiteSettingsPage.tsxGeneral (logo upload ≤ 1 MB, workspace name / support email / tagline), Regional selects, Appearance (theme-mode tiles via useTheme + "More options" → /layout-settings), feature Switch rows, and the danger-zone panel + confirm Modal.
src/pages/settings/PermissionsPage.tsxRole overview cards, the permission matrix, the team-member list with per-member role Select, and the invite Modal.
src/pages/settings/PaymentPage.tsxPlan hero + billing summary, usage tiles, CardFace payment-method visuals, add-card Modal, and the billing-history table.
src/pages/settings/MailSettingsPage.tsxProvider segmented control, SMTP-vs-API config branch, sender identity, and the send-test panel.
src/data/settings.tsAll demo data: TIMEZONES, DATE_FORMATS, FEATURE_TOGGLES, ROLES, PERMISSION_GROUPS + defaultMatrix(), MEMBERS, SAVED_CARDS, BILLING_HISTORY, MAIL_PROVIDERS, ENCRYPTION_OPTS (+ their types).
src/locales/en/settings.jsonThe settings i18n namespace (registered in src/i18n/languages.ts + en/manifest.json); tab titles reuse nav:* keys.

Presentational by design

There is no STORAGE_KEY and nothing registered in src/lib/appStorage.ts — the hub keeps no persisted state. Avatar / cover / logo uploads become in-memory data URLs; edited fields, matrix cells, member roles, and saved cards live in component useState and reset on navigation. Currency amounts on the Payment page use the shared formatMoney(amount, currency, locale) from src/data/invoice.ts, and the Regional currency Select reuses its CURRENCIES list — the hub adds no duplicate formatters. The Payment billing summary derives its figures (days left, period progress, next payment date) from today's date so the demo always looks current.

Usage

The hub is already wired in — the five leaves live under Settings → Settings in the sidebar (src/data/menu.ts), and each is a plain route:

{path: '/settings/profile', element: <ProfilePage />},
{path: '/settings', element: <SiteSettingsPage />},
{path: '/settings/permissions', element: <PermissionsPage />},
{path: '/settings/payment', element: <PaymentPage />},
{path: '/settings/mail', element: <MailSettingsPage />},

Each page is just SettingsLayout + Panels, so a new tab is a few lines:

import {useTranslation} from '@/platform/i18n'
import {Panel} from '@/components/ui'
import {SettingsLayout} from '@/components/settings/SettingsLayout'
 
export function NotificationsSettingsPage() {
    const {t} = useTranslation('settings')
    return (
        <SettingsLayout title={t('nav:notificationSettings')}>
            <Panel title={t('settings:notifTitle')} subtitle={t('settings:notifHint')}>
                {/* form controls */}
            </Panel>
        </SettingsLayout>
    )
}

Register it in three places (the standard real-page recipe, see Architecture & Routing) plus the hub tab row: a route for it, a menu leaf in src/data/menu.ts, the nav: label, and an entry in ITEMS in src/components/settings/SettingsNav.tsx.

API / Props

SettingsLayoutProps (SettingsLayout.tsx)

PropTypeDescription
titlestringThe PageHeader title (each page passes its nav:* label).
childrenReactNodeThe tab's content — Panels rendered in the main (left) column.

The layout itself owns the "Save changes" action (a size="xs" Button that toasts settings:savedToast), the banner, and the aside — pages provide only their panels.

SettingsNav tab registry

SettingsNav renders a fixed ITEMS array — {to, navKey, icon, end?} per tab:

tonavKeyIconNotes
/settings/profilenav:profileUserRound
/settingsnav:siteSettingsSettings2end: true (exact-match only)
/settings/permissionsnav:permissionsShieldCheck
/settings/paymentnav:paymentSettingsCreditCard
/settings/mailnav:mailSettingsMail

Demo data (src/data/settings.ts)

ExportTypeUsed by
TIMEZONES / DATE_FORMATS{value; label}[]Site Settings → Regional Selects (labels are literal/technical).
FEATURE_TOGGLESFeatureToggle[] (id, labelKey, descKey, on)Site Settings → Features Switch rows.
ROLESRole[] (id, labelKey, members, locked?)Permissions — role cards, matrix columns, role selects (owner is locked).
PERMISSION_GROUPSPermissionGroup[] (id, labelKey, defaults)Permissions — matrix rows; defaults lists role ids granted by default.
defaultMatrix()() => Record<string, Record<string, boolean>>Builds matrix[groupId][roleId] from the groups' defaults.
MEMBERSMember[] (id, name, email, avatar?, role)Permissions — team-member list.
SAVED_CARDSSavedCard[] (id, brand, last4, exp, primary?)Payment — CardFace visuals.
BILLING_HISTORYInvoiceRow[] (id, date, amount, statusKey)Payment — billing-history table.
MAIL_PROVIDERS{id; labelKey?; label?}[]Mail — segmented control (smtp uses an i18n key; SendGrid / Mailgun / Postmark are literal brand names).
ENCRYPTION_OPTS{value; label}[]Mail — SMTP encryption Select (None / SSL / TLS).

Per-page behaviors worth knowing

  • Permissions matrix — one Checkbox per group×role cell; the Owner column is locked (role.locked → disabled) and each cell carries an aria-label of "permission — role". The owner member's role Select is disabled and their remove button is hidden (invisible).
  • Payment CardFace — a realistic card mockup: per-brand gradient face (BRAND_STYLE: Visa blue, Mastercard near-black, slate fallback), a drawn EMV CardChip, contactless glyph, brand.name as the issuer line, masked number from last4, holder + expiry, and a BrandLogo (VISA wordmark / Mastercard interlocking circles). Non-primary cards get Make default and remove actions; the dashed add tile opens the add-card Modal.
  • Mail config branch — provider === 'smtp' renders host / port / encryption / username / password; any other provider renders a single API-key field. From-name / from-email are shared below the branch.
  • Site Settings danger zone — a headerVariant="muted", border-danger/30 Panel whose delete action opens a size="sm" confirm Modal (both buttons just close it — presentational).

Configuration & customization

Add a hub tab

  1. Create the page (compose SettingsLayout + Panels — every Panel needs title and subtitle).
  2. Register the route.
  3. Add the menu leaf under the Settings group in src/data/menu.ts + its nav: label.
  4. Append the tab to ITEMS in src/components/settings/SettingsNav.tsx.

Edit roles & permissions

Both are data-driven from src/data/settings.ts. A new role is a Role entry (its column, role-select option, and matrix cells all derive automatically); a new permission is a PermissionGroup with the role ids that get it by default. Add the settings:role* / settings:perm* labels (role cards also read a <labelKey>Desc key for the description line).

Feature toggles, providers, cards, history

Same pattern — edit FEATURE_TOGGLES, MAIL_PROVIDERS, SAVED_CARDS, or BILLING_HISTORY and the UI follows. Card brands map to a gradient in BRAND_STYLE (PaymentPage.tsx); unknown brands fall back to the slate face.

Identity & imagery

The banner identity (name "Alex Morgan", role chip, location / company / joined chips, the three stats) is literal demo content in ProfileHeader.tsx (STATS + inline JSX); the default cover is /photos/photo-2.jpg and the default avatar /avatars/avatar-1.png (self-hosted, like all avatars). The plan widget's "Pro / $29/mo" and usage figures are literal in SettingsAside.tsx.

A note on colors

Everything themable uses semantic tokens (bg-surface, border-border, bg-primary, text-muted-foreground, danger tints for the danger zone) so all tabs re-skin and dark-mode automatically. The only deliberate exceptions are decorative branded graphics: the CardFace gradients / gold EMV chip and the Mastercard circles (#eb001b / #f79e1b) are intentionally fixed — a credit card doesn't change color with the app skin — and the cover-hero scrim/chips use white/black overlays because they sit on a photograph.

Examples

Add a permission group

// src/data/settings.ts
export const PERMISSION_GROUPS: PermissionGroup[] = [
    // …
    {id: 'manageApiKeys', labelKey: 'settings:permManageApiKeys', defaults: ['owner', 'admin']},
]
// src/locales/en/settings.json
{"permManageApiKeys": "Manage API keys"}

The matrix row, checkboxes, and default checks appear automatically (via defaultMatrix()).

Wire a form to a real API

Replace the toast in your page (or in SettingsLayout's Save action) with a call through the shared HTTP client:

import {api} from '@/lib/api'
 
const onSave = async () => {
    await api.put('/workspace/settings', {name, tagline, email})
    toast({title: t('settings:savedToast'), tone: 'success'})
}

Best practices

  • Keep pages as Panel compositions. Every content section is a Panel with title + subtitle (the project-wide convention — see Panel); page files stay wiring-only.
  • Route theme changes through useTheme. The Appearance tiles call setMode on the shared theme store — never shadow it with local state, or the header toggle / customizer drift out of sync.
  • Data in src/data/settings.ts, copy in the settings namespace. Rows/roles/cards are data entries; user-facing labels are i18n keys resolved at render. Proper nouns (SendGrid, Visa), technical values (ports, timezone labels), and card numbers stay literal.
  • If you add persistence, register the key. The hub currently persists nothing; the moment you add a localStorage key, add it to APP_LOCAL_KEYS in src/lib/appStorage.ts so "Reset to defaults" clears it.
  • Reuse the shared money formatter. formatMoney from src/data/invoice.ts is the repo's only Intl.NumberFormat currency helper — don't inline another.

Troubleshooting

SymptomCause / fix
The Site Settings tab lights up on every /settings/* pageIts NavLink needs end: true (it has it in ITEMS) — /settings is a path prefix of all the others.
Edits vanish when switching tabsBy design — state is local per page and nothing persists. Wire the forms to your API for real behavior.
"Save changes" doesn't save anythingIt is presentational: a success toast only. See the API example above.
Owner checkboxes / role select / remove can't be changedDeliberate — the owner role is locked in ROLES and the owner member is guarded in PermissionsPage. Remove the guards if your model differs.
The right rail scrolls away / overlaps the sticky page headerThe aside is lg:sticky lg:top-32 — tuned to clear the app header + slim PageHeader. Adjust the offset if you change header heights.
Avatar / cover / logo upload does nothingFiles over the size cap are silently ignored (cover ≤ 2 MB, avatar/logo ≤ 1 MB) and only image/* is accepted.
Card faces don't re-skinIntentional — BRAND_STYLE gradients are fixed decorative brand graphics, not tokens.

FAQ

How is this different from /layout-settings? The Settings hub demos application settings (profile, workspace, roles, billing, email) and is presentational. Layout Settings edits the real shell configuration and persists it via LayoutContext. See Customizer & Layout Settings.

Does anything here persist? Only the theme mode changed from the Appearance section (through useTheme, localStorage('theme')). Everything else resets on navigation.

Where do the Payment page's dates and totals come from? Computed from today's date at render (billing-period progress, days left, next payment via Intl.DateTimeFormat in the active language) and from the cycle toggle ($29 monthly / $24 annual per seat × 12 seats), so the demo never looks stale.

Can members actually be invited / removed? Removals and role changes update local state (so the list responds live); the invite modal validates nothing and just toasts settings:invitedToast.

Why is the profile banner shown on every tab, not just Profile? It doubles as the hub's frame — the tabs are integrated along its bottom strip, so the five pages read as one settings area.

Notes for designers & content editors

  • All hub copy — panel titles/hints, labels, toasts, role and permission names — lives in src/locales/<lng>/settings.json; tab labels come from the nav namespace. Edit copy there, never in the components. (English + Japanese ship at parity.)
  • Demo identity ("Alex Morgan", "Northwind Studio", emails, member names, invoice ids/dates) is literal demo content in src/data/settings.ts and the page/ProfileHeader JSX — safe to replace wholesale for a preview.
  • Imagery: the cover photo is /photos/photo-2.jpg, avatars come from /avatars/avatar-N.png — swap the files or the paths; uploads only preview in-memory.
  • Colors come from semantic tokens and re-skin/dark-mode automatically, except the deliberately fixed credit-card faces and the photo-overlay whites in the banner.

Was this page helpful?