mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
polish(desktop/billing): auto-poll, no-card notice, grouped card layout
Post-merge review polish of #68722 + #68761: - Usage: drop the manual refresh + 'Updated Xm ago'; billing queries now refetchInterval-poll while the page is mounted, like every other data view. - No card: lead the page with a warn notice naming the blocker + 'Add card ↗', so the silently-disabled buy/auto-refill controls have an obvious cause. - Layout: unify on a shared SettingsCard/SettingsSection primitive; collapse the three floating one-row sections (Payment / One-time top-up / Automatic refill) into a single divide-y 'Payment & credits' card. - Dev fixture switcher: relabel as a wrench + 'preview' dashed control so it reads as the DEV-only tool it is (compiled out of production). Removed formatUsageUpdatedAgo/oldestUpdatedAt/UsageRefreshRow + their tests; added no-card-notice and no-manual-refresh tests. vitest 107 green, tsc + eslint clean.
This commit is contained in:
parent
e0d62b509e
commit
f9a8ecc0ce
5 changed files with 158 additions and 204 deletions
|
|
@ -15,7 +15,6 @@ import {
|
|||
todayBillingState,
|
||||
todaySubscriptionState
|
||||
} from './fixtures.test-util'
|
||||
import { formatUsageUpdatedAgo } from './use-billing-state'
|
||||
|
||||
import { BillingSettings } from './index'
|
||||
|
||||
|
|
@ -265,7 +264,7 @@ describe('BillingSettings', () => {
|
|||
// plans capability, so the URL must not surface a grid of Choose buttons.
|
||||
renderBilling(['/settings?tab=billing&bview=plans'])
|
||||
|
||||
expect(await screen.findByText('Payment')).toBeTruthy()
|
||||
expect(await screen.findByText('Payment & credits')).toBeTruthy()
|
||||
expect(screen.queryByText('Plans')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
|
||||
})
|
||||
|
|
@ -277,7 +276,7 @@ describe('BillingSettings', () => {
|
|||
|
||||
renderBilling(['/settings?tab=billing&bview=plans'])
|
||||
|
||||
expect(await screen.findByText('Payment')).toBeTruthy()
|
||||
expect(await screen.findByText('Payment & credits')).toBeTruthy()
|
||||
expect(screen.queryByText('Plans')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
|
||||
})
|
||||
|
|
@ -314,7 +313,7 @@ describe('BillingSettings', () => {
|
|||
await waitFor(() => expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal'))
|
||||
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }))
|
||||
// Scheduled → back on the overview.
|
||||
expect(await screen.findByText('Payment')).toBeTruthy()
|
||||
expect(await screen.findByText('Payment & credits')).toBeTruthy()
|
||||
expect(screen.queryByText('Plans')).toBeNull()
|
||||
})
|
||||
|
||||
|
|
@ -653,65 +652,34 @@ describe('BillingSettings', () => {
|
|||
expect(monthlyCapTrack.classList.contains('bg-(--ui-bg-elevated)')).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes both billing queries from the usage refresh button', async () => {
|
||||
it('shows a warn notice that names the no-card blocker with a portal link', async () => {
|
||||
const fixture = billingDevFixtures['no-card']
|
||||
|
||||
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
|
||||
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
|
||||
|
||||
renderBilling()
|
||||
|
||||
expect(await screen.findByText('No payment method on file')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: /Add card/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not show the no-card notice when a card is on file', async () => {
|
||||
renderBilling()
|
||||
|
||||
await screen.findByText('$996.47')
|
||||
expect(screen.queryByText('No payment method on file')).toBeNull()
|
||||
})
|
||||
|
||||
it('polls billing on an interval without a manual refresh control', async () => {
|
||||
renderBilling()
|
||||
|
||||
await screen.findByText('$120 of $220 left')
|
||||
expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(1)
|
||||
expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
|
||||
|
||||
await waitFor(() => expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(2))
|
||||
expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('disables the usage refresh button while either query is fetching', async () => {
|
||||
let settleBilling: (value: unknown) => void = () => {}
|
||||
|
||||
let settleSubscription: (value: unknown) => void = () => {}
|
||||
|
||||
apiMocks.fetchBillingState.mockResolvedValueOnce(okBilling(todayBillingState)).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
settleBilling = resolve
|
||||
})
|
||||
)
|
||||
apiMocks.fetchSubscriptionState.mockResolvedValueOnce(okSubscription(todaySubscriptionState)).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
settleSubscription = resolve
|
||||
})
|
||||
)
|
||||
|
||||
renderBilling()
|
||||
|
||||
const refresh = await screen.findByRole('button', { name: 'Refresh' })
|
||||
|
||||
fireEvent.click(refresh)
|
||||
|
||||
await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(true))
|
||||
|
||||
settleBilling(okBilling(todayBillingState))
|
||||
settleSubscription(okSubscription(todaySubscriptionState))
|
||||
|
||||
await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(false))
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatUsageUpdatedAgo', () => {
|
||||
it('formats sub-second and current timestamps as just now', () => {
|
||||
expect(formatUsageUpdatedAgo(1_000, 1_000)).toBe('just now')
|
||||
expect(formatUsageUpdatedAgo(1_500, 1_000)).toBe('just now')
|
||||
})
|
||||
|
||||
it('formats seconds below a minute', () => {
|
||||
expect(formatUsageUpdatedAgo(1_000, 60_000)).toBe('59s ago')
|
||||
})
|
||||
|
||||
it('rounds elapsed time to whole minutes from 61 seconds', () => {
|
||||
expect(formatUsageUpdatedAgo(1_000, 62_000)).toBe('1m ago')
|
||||
})
|
||||
|
||||
it('formats one hour and later as hours', () => {
|
||||
expect(formatUsageUpdatedAgo(1_000, 3_601_000)).toBe('1h ago')
|
||||
// The manual refresh affordance is gone — the queries poll on their own.
|
||||
expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull()
|
||||
expect(screen.queryByText(/Updated/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@ import { useEffect, useMemo, useState } from 'react'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { BarChart3, ExternalLink, Lock, Package, Plus, RefreshCw } from '@/lib/icons'
|
||||
import { BarChart3, CreditCard, ExternalLink, Package, Wrench } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useRouteEnumParam } from '../../hooks/use-route-enum-param'
|
||||
import { ListRow, SectionHeading, SettingsContent } from '../primitives'
|
||||
import { ListRow, SectionHeading, SettingsCard, SettingsContent, SettingsSection } from '../primitives'
|
||||
|
||||
import { RowValue } from './account-row-value'
|
||||
import { BillingApiProvider } from './api'
|
||||
|
|
@ -27,7 +26,6 @@ import {
|
|||
type BillingNoticeView,
|
||||
type BillingUsageRowView,
|
||||
deriveBillingView,
|
||||
formatUsageUpdatedAgo,
|
||||
useBillingState,
|
||||
useSubscriptionState
|
||||
} from './use-billing-state'
|
||||
|
|
@ -64,9 +62,23 @@ function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | '
|
|||
}
|
||||
|
||||
function NoticeCard({ notice }: { notice: BillingNoticeView }) {
|
||||
const warn = notice.tone === 'warn'
|
||||
|
||||
return (
|
||||
<div className="mb-5 rounded-lg border border-border/70 bg-muted/20 p-4">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{notice.title}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'mb-6 rounded-xl border p-4',
|
||||
warn ? 'border-amber-500/30 bg-amber-500/5' : 'border-border/70 bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'text-[length:var(--conversation-text-font-size)] font-medium',
|
||||
warn ? 'text-amber-600 dark:text-amber-300' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{notice.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{notice.message}
|
||||
</div>
|
||||
|
|
@ -351,53 +363,10 @@ function UsageRow({ row }: { row: BillingUsageRowView }) {
|
|||
)
|
||||
}
|
||||
|
||||
function UsageRefreshRow({
|
||||
fixtureName,
|
||||
isFetching,
|
||||
onRefresh,
|
||||
updatedAt
|
||||
}: {
|
||||
fixtureName?: BillingFixtureSelection
|
||||
isFetching: boolean
|
||||
onRefresh: () => void
|
||||
updatedAt: number
|
||||
}) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => setNow(Date.now()), 30_000)
|
||||
|
||||
return () => window.clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (fixtureName && fixtureName !== 'live') {
|
||||
return (
|
||||
<div className="flex items-center justify-end pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
fixture: {fixtureName}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-end gap-1.5 pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<span>Updated {formatUsageUpdatedAgo(updatedAt, now)}</span>
|
||||
<Tip label="Refresh">
|
||||
<Button
|
||||
aria-label="Refresh"
|
||||
className="size-7 p-0 text-(--ui-text-tertiary)"
|
||||
disabled={isFetching}
|
||||
onClick={onRefresh}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', isFetching && 'animate-spin')} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DEV-only preview switcher: swaps the whole page onto a canned fixture so every
|
||||
// billing state can be reviewed without a matching live account. Marked with a
|
||||
// wrench + "preview" so it never reads as a shipping control (it's compiled out of
|
||||
// production builds entirely).
|
||||
function BillingFixtureSelect({
|
||||
onValueChange,
|
||||
value
|
||||
|
|
@ -406,23 +375,27 @@ function BillingFixtureSelect({
|
|||
value: BillingFixtureSelection
|
||||
}) {
|
||||
return (
|
||||
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
|
||||
<SelectTrigger
|
||||
aria-label="Billing fixture"
|
||||
className="h-7 w-32 border-transparent bg-transparent px-1.5 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-muted/40 focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-muted/40"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="live">live</SelectItem>
|
||||
{BILLING_DEV_FIXTURE_NAMES.map(name => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5 text-(--ui-text-tertiary)">
|
||||
<Wrench className="size-3.5 shrink-0" />
|
||||
<span className="text-xs font-normal">preview</span>
|
||||
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
|
||||
<SelectTrigger
|
||||
aria-label="Billing preview fixture (dev only)"
|
||||
className="h-7 w-36 border-dashed border-(--ui-stroke-secondary) bg-transparent px-2 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-muted/40 focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-muted/40"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="live">live</SelectItem>
|
||||
{BILLING_DEV_FIXTURE_NAMES.map(name => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -464,15 +437,16 @@ function BillingSettingsContent({
|
|||
const subscriptionResult = subscriptionState.data
|
||||
const view = deriveBillingView(billingResult, subscriptionResult)
|
||||
const billing = billingResult?.ok ? billingResult.data : undefined
|
||||
const usageUpdatedAt = oldestUpdatedAt(billingState.dataUpdatedAt, subscriptionState.dataUpdatedAt)
|
||||
const usageIsFetching = billingState.isFetching || subscriptionState.isFetching
|
||||
|
||||
const refreshUsage = () => {
|
||||
void Promise.all([billingState.refetch(), subscriptionState.refetch()])
|
||||
}
|
||||
|
||||
const { paymentRow, refillRow, topupRow } = view
|
||||
|
||||
// The Payment & credits card groups every money control (payment method,
|
||||
// one-time top-up, auto-refill) into a single divide-y list instead of three
|
||||
// free-floating one-row sections.
|
||||
const accountRows = [paymentRow, topupRow, refillRow].filter(
|
||||
(row): row is BillingAccountRowView => row !== undefined
|
||||
)
|
||||
|
||||
// Gate the plans sub-view on the SAME capability that renders the in-app button
|
||||
// (`plan.action`): a team / non-changer deep-linking `bview=plans` must never
|
||||
// reach a grid of live Choose buttons — it falls back to the overview.
|
||||
|
|
@ -491,59 +465,42 @@ function BillingSettingsContent({
|
|||
<SettingsContent>
|
||||
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
|
||||
|
||||
<div className="@container mb-5">
|
||||
<div className="grid gap-3 rounded-lg border border-border/70 bg-muted/20 p-4 @2xl:grid-cols-3">
|
||||
{view.notice && <NoticeCard notice={view.notice} />}
|
||||
|
||||
<div className="@container mb-6">
|
||||
<SettingsCard className="grid gap-3 p-4 @2xl:grid-cols-3">
|
||||
{view.summary.map(item => (
|
||||
<SummaryCard key={item.label} label={item.label} tone={item.tone} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
|
||||
{view.notice && <NoticeCard notice={view.notice} />}
|
||||
|
||||
{view.plan && (
|
||||
<div className="mb-5">
|
||||
<SectionHeading icon={Package} title="Plan" />
|
||||
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
|
||||
</div>
|
||||
<SettingsSection icon={Package} title="Plan">
|
||||
<SettingsCard className="px-4">
|
||||
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{paymentRow && (
|
||||
<div className="mb-5">
|
||||
<SectionHeading icon={Lock} title="Payment" />
|
||||
<AccountRow billing={billing} row={paymentRow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{topupRow && (
|
||||
<div className="mb-5">
|
||||
<SectionHeading icon={Plus} title="One-time top-up" />
|
||||
<AccountRow billing={billing} row={topupRow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{refillRow && (
|
||||
<div className="mb-5">
|
||||
<SectionHeading icon={RefreshCw} title="Automatic refill" />
|
||||
<AccountRow billing={billing} row={refillRow} />
|
||||
</div>
|
||||
{accountRows.length > 0 && (
|
||||
<SettingsSection icon={CreditCard} title="Payment & credits">
|
||||
<SettingsCard className="divide-y divide-border/60 px-4">
|
||||
{accountRows.map(row => (
|
||||
<AccountRow billing={billing} key={row.id} row={row} />
|
||||
))}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{view.usageRows.length > 0 && (
|
||||
<>
|
||||
<SectionHeading icon={BarChart3} title="Usage" />
|
||||
<div className="@container rounded-lg border border-border/70 bg-muted/20 px-4 py-2">
|
||||
<SettingsSection icon={BarChart3} title="Usage">
|
||||
<SettingsCard className="@container px-4 py-2">
|
||||
{view.usageRows.map(row => (
|
||||
<UsageRow key={row.id} row={row} />
|
||||
))}
|
||||
<UsageRefreshRow
|
||||
fixtureName={fixtureName}
|
||||
isFetching={usageIsFetching}
|
||||
onRefresh={refreshUsage}
|
||||
updatedAt={usageUpdatedAt}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{
|
||||
|
|
@ -586,9 +543,3 @@ export function BillingSettings() {
|
|||
|
||||
return <BillingSettingsContent />
|
||||
}
|
||||
|
||||
function oldestUpdatedAt(...timestamps: number[]): number {
|
||||
const populated = timestamps.filter(timestamp => timestamp > 0)
|
||||
|
||||
return populated.length > 0 ? Math.min(...populated) : Date.now()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ export const EMPTY_BILLING_VALUE = '—'
|
|||
export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing'
|
||||
export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com'
|
||||
|
||||
// Billing polls on its own while the page is mounted (react-query only ticks an
|
||||
// active observer), so the view stays live without a manual refresh control —
|
||||
// matching every other data view in the app. It pauses when the window is
|
||||
// backgrounded (refetchIntervalInBackground defaults to false).
|
||||
const BILLING_QUERY_OPTIONS = {
|
||||
refetchInterval: 30_000,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: false,
|
||||
staleTime: 30_000
|
||||
|
|
@ -30,6 +35,8 @@ export interface BillingNoticeView {
|
|||
}
|
||||
message: string
|
||||
title: string
|
||||
/** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */
|
||||
tone?: 'info' | 'warn'
|
||||
}
|
||||
|
||||
export interface BillingRowActionView {
|
||||
|
|
@ -207,7 +214,7 @@ export function deriveBillingView(
|
|||
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending)
|
||||
|
||||
return {
|
||||
notice: undefined,
|
||||
notice: noCardNotice(billing),
|
||||
paymentRow: paymentMethodRow(billing),
|
||||
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending),
|
||||
refillRow: autoReloadRow(billing),
|
||||
|
|
@ -276,26 +283,6 @@ export function formatBillingDate(value?: null | string): string {
|
|||
return fmtDate.format(date)
|
||||
}
|
||||
|
||||
export function formatUsageUpdatedAgo(updatedAt: number, now: number): string {
|
||||
const elapsedSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000))
|
||||
|
||||
if (elapsedSeconds < 1) {
|
||||
return 'just now'
|
||||
}
|
||||
|
||||
if (elapsedSeconds < 60) {
|
||||
return `${elapsedSeconds}s ago`
|
||||
}
|
||||
|
||||
const elapsedMinutes = Math.floor(elapsedSeconds / 60)
|
||||
|
||||
if (elapsedMinutes < 60) {
|
||||
return `${elapsedMinutes}m ago`
|
||||
}
|
||||
|
||||
return `${Math.floor(elapsedMinutes / 60)}h ago`
|
||||
}
|
||||
|
||||
function emptySummary(): BillingSummaryItemView[] {
|
||||
return [
|
||||
{ label: 'Balance', value: EMPTY_BILLING_VALUE },
|
||||
|
|
@ -311,7 +298,24 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
|
|||
return {
|
||||
action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined,
|
||||
message: resolved.message,
|
||||
title: resolved.title
|
||||
title: resolved.title,
|
||||
tone: 'warn'
|
||||
}
|
||||
}
|
||||
|
||||
// A logged-in account with no card can't buy credits or manage auto-refill, and
|
||||
// every one of those controls disables silently — so lead the page with a single
|
||||
// warn banner that names the blocker and links straight to the fix.
|
||||
function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefined {
|
||||
if (billing.card) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
action: { label: 'Add card ↗', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
|
||||
message: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.',
|
||||
title: 'No payment method on file',
|
||||
tone: 'warn'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,35 @@ export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponen
|
|||
)
|
||||
}
|
||||
|
||||
// The canonical settings surface: a soft-bordered muted well. Callers own the
|
||||
// inner padding (a `divide-y` list wants none; a single block wants `p-4`) so the
|
||||
// one container styling stays consistent across every settings page.
|
||||
export function SettingsCard({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cn('rounded-xl border border-border/70 bg-muted/20', className)}>{children}</div>
|
||||
}
|
||||
|
||||
// A titled section: heading + body with the shared vertical rhythm. Keeps the
|
||||
// heading and its content welded together so pages stop hand-rolling
|
||||
// `<div className="mb-…"><SectionHeading/>…</div>` at every call site.
|
||||
export function SettingsSection({
|
||||
children,
|
||||
icon,
|
||||
meta,
|
||||
title
|
||||
}: {
|
||||
children: ReactNode
|
||||
icon: IconComponent
|
||||
meta?: string
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<SectionHeading icon={icon} meta={meta} title={title} />
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavLink({
|
||||
icon: Icon,
|
||||
label,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
IconCopy as Copy,
|
||||
IconCopy as CopyIcon,
|
||||
IconCpu as Cpu,
|
||||
IconCreditCard as CreditCard,
|
||||
IconDownload as Download,
|
||||
IconEgg as Egg,
|
||||
IconExternalLink as ExternalLink,
|
||||
|
|
@ -155,6 +156,7 @@ export {
|
|||
Copy,
|
||||
CopyIcon,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Download,
|
||||
Egg,
|
||||
ExternalLink,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue