feat(dashboard-shell): add @bytelyst/dashboard-shell package (4.3) — 41 tests
Components: - DashboardShell — main layout combining sidebar + topbar + content area - Sidebar — collapsible nav with sections, badges, active state, auto-settings link - TopBar — user avatar/menu, notifications bell, sign out, custom actions - ProfilePage — avatar, name/email form, loading/error/success states - BillingPage — current plan card, status badge, trial info, plan comparison grid - SettingsPage — section-based layout with empty state Features: - NavItem[] or NavSection[] for flat or grouped navigation - ShellFeatures toggle: profile, billing, settings, notifications, themeToggle - ShellUser with avatar, role, initials fallback - onNavigate callback for SPA routers (Next.js, etc.) - Collapsible sidebar with toggle button - All styled via --bl-shell-* CSS custom properties with fallbacks - 41 tests covering all components
This commit is contained in:
parent
6354711f97
commit
1fda345d38
36
packages/dashboard-shell/package.json
Normal file
36
packages/dashboard-shell/package.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@bytelyst/dashboard-shell",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Configurable Next.js dashboard layout with sidebar, profile, billing, and settings pages",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"react-dom": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"happy-dom": "^18.0.1",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
189
packages/dashboard-shell/src/BillingPage.tsx
Normal file
189
packages/dashboard-shell/src/BillingPage.tsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { BillingPageProps } from './types.js';
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
active: 'var(--color-success, #16a34a)',
|
||||||
|
trialing: 'var(--color-warning, #d97706)',
|
||||||
|
past_due: 'var(--color-destructive, #dc2626)',
|
||||||
|
canceled: 'var(--color-muted-foreground, #6b7280)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BillingPage({
|
||||||
|
currentPlan = 'Free',
|
||||||
|
status = 'active',
|
||||||
|
trialEndsAt,
|
||||||
|
onManageBilling,
|
||||||
|
plans = [],
|
||||||
|
}: BillingPageProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div data-testid="bl-shell-billing-page" style={{ maxWidth: 800 }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 24,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Billing
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Current plan card */}
|
||||||
|
<div
|
||||||
|
data-testid="bl-billing-current"
|
||||||
|
style={{
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
marginBottom: 32,
|
||||||
|
background: 'var(--color-surface, #fff)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-muted-foreground, #6b7280)',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Current Plan
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-foreground, #111827)' }}
|
||||||
|
>
|
||||||
|
{currentPlan}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
data-testid="bl-billing-status"
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: 20,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: statusColors[status] || statusColors.active,
|
||||||
|
background: `color-mix(in srgb, ${statusColors[status] || statusColors.active} 10%, transparent)`,
|
||||||
|
border: `1px solid color-mix(in srgb, ${statusColors[status] || statusColors.active} 30%, transparent)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trialEndsAt && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-billing-trial"
|
||||||
|
style={{ fontSize: 14, color: 'var(--color-warning, #d97706)', marginBottom: 12 }}
|
||||||
|
>
|
||||||
|
Trial ends: {trialEndsAt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onManageBilling && (
|
||||||
|
<button
|
||||||
|
data-testid="bl-billing-manage"
|
||||||
|
onClick={onManageBilling}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'var(--color-surface, #fff)',
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage Billing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan comparison */}
|
||||||
|
{plans.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 16,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Available Plans
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
data-testid="bl-billing-plans"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${Math.min(plans.length, 3)}, 1fr)`,
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plans.map(plan => (
|
||||||
|
<div
|
||||||
|
key={plan.name}
|
||||||
|
data-testid={`bl-billing-plan-${plan.name.toLowerCase()}`}
|
||||||
|
style={{
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: plan.current
|
||||||
|
? '2px solid var(--bl-shell-accent, var(--color-primary, #2563eb))'
|
||||||
|
: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
background: 'var(--color-surface, #fff)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 600, marginBottom: 4 }}>{plan.name}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 16,
|
||||||
|
color: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plan.price}
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||||
|
{plan.features.map(f => (
|
||||||
|
<li
|
||||||
|
key={f}
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
padding: '4px 0',
|
||||||
|
color: 'var(--color-muted-foreground, #6b7280)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓ {f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{plan.current && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Current Plan
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
packages/dashboard-shell/src/DashboardShell.tsx
Normal file
73
packages/dashboard-shell/src/DashboardShell.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import type { DashboardShellProps } from './types.js';
|
||||||
|
import { Sidebar } from './Sidebar.js';
|
||||||
|
import { TopBar } from './TopBar.js';
|
||||||
|
|
||||||
|
export function DashboardShell({
|
||||||
|
productName,
|
||||||
|
logo,
|
||||||
|
version,
|
||||||
|
nav,
|
||||||
|
pathname: externalPathname,
|
||||||
|
user,
|
||||||
|
features = {},
|
||||||
|
onSignOut,
|
||||||
|
onNavigate,
|
||||||
|
sidebarFooter,
|
||||||
|
topBarActions,
|
||||||
|
children,
|
||||||
|
}: DashboardShellProps): ReactNode {
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
|
// Use external pathname or default to '/'
|
||||||
|
const pathname = externalPathname ?? '/';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="bl-dashboard-shell"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'var(--bl-shell-bg, var(--color-background, #f9fafb))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Sidebar
|
||||||
|
productName={productName}
|
||||||
|
logo={logo}
|
||||||
|
version={version}
|
||||||
|
nav={nav}
|
||||||
|
pathname={pathname}
|
||||||
|
features={features}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
footer={sidebarFooter}
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
{/* Top bar */}
|
||||||
|
<TopBar
|
||||||
|
user={user}
|
||||||
|
features={features}
|
||||||
|
onSignOut={onSignOut}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
actions={topBarActions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main
|
||||||
|
data-testid="bl-shell-main"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '24px 32px',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
packages/dashboard-shell/src/ProfilePage.tsx
Normal file
180
packages/dashboard-shell/src/ProfilePage.tsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import type { ProfilePageProps } from './types.js';
|
||||||
|
|
||||||
|
export function ProfilePage({
|
||||||
|
user,
|
||||||
|
onUpdateProfile,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
}: ProfilePageProps): ReactNode {
|
||||||
|
const [name, setName] = useState(user.name);
|
||||||
|
const [email, setEmail] = useState(user.email);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (onUpdateProfile) onUpdateProfile({ name, email });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="bl-shell-profile-page" style={{ maxWidth: 600 }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 24,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div data-testid="bl-profile-error" style={alertStyle('var(--color-destructive, #dc2626)')}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div data-testid="bl-profile-success" style={alertStyle('var(--color-success, #16a34a)')}>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 32 }}>
|
||||||
|
<div
|
||||||
|
data-testid="bl-profile-avatar"
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
||||||
|
color: '#fff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt={user.name}
|
||||||
|
style={{ width: 64, height: 64, borderRadius: '50%' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
user.name
|
||||||
|
.split(' ')
|
||||||
|
.map(w => w[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 600 }}>{user.name}</div>
|
||||||
|
<div style={{ fontSize: 14, color: 'var(--color-muted-foreground, #6b7280)' }}>
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
{user.role && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-muted-foreground, #6b7280)',
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Role: {user.role}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="bl-profile-name">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bl-profile-name"
|
||||||
|
data-testid="bl-profile-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle} htmlFor="bl-profile-email">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bl-profile-email"
|
||||||
|
data-testid="bl-profile-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onUpdateProfile && (
|
||||||
|
<button
|
||||||
|
data-testid="bl-profile-submit"
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: 'none',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
||||||
|
color: '#fff',
|
||||||
|
opacity: isLoading ? 0.6 : 1,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 6,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
fontSize: 14,
|
||||||
|
background: 'var(--color-surface, #fff)',
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
function alertStyle(color: string): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
fontSize: 14,
|
||||||
|
color,
|
||||||
|
background: `color-mix(in srgb, ${color} 10%, transparent)`,
|
||||||
|
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
71
packages/dashboard-shell/src/SettingsPage.tsx
Normal file
71
packages/dashboard-shell/src/SettingsPage.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { SettingsPageProps } from './types.js';
|
||||||
|
|
||||||
|
export function SettingsPage({ productName, sections = [] }: SettingsPageProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div data-testid="bl-shell-settings-page" style={{ maxWidth: 700 }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 24,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{sections.length === 0 && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-settings-empty"
|
||||||
|
style={{
|
||||||
|
padding: 32,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--color-muted-foreground, #6b7280)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No settings configured for {productName}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sections.map((section, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
data-testid={`bl-settings-section-${i}`}
|
||||||
|
style={{
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
marginBottom: 16,
|
||||||
|
background: 'var(--color-surface, #fff)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 4,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
{section.description && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-muted-foreground, #6b7280)',
|
||||||
|
marginBottom: 16,
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div>{section.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
packages/dashboard-shell/src/Sidebar.tsx
Normal file
236
packages/dashboard-shell/src/Sidebar.tsx
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { SidebarProps, NavItem, NavSection } from './types.js';
|
||||||
|
|
||||||
|
function isNavSections(nav: NavItem[] | NavSection[]): nav is NavSection[] {
|
||||||
|
return nav.length > 0 && 'items' in nav[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({
|
||||||
|
item,
|
||||||
|
active,
|
||||||
|
collapsed,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
item: NavItem;
|
||||||
|
active: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
onNavigate?: (href: string) => void;
|
||||||
|
}): ReactNode {
|
||||||
|
if (item.hidden) return null;
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
if (onNavigate) {
|
||||||
|
e.preventDefault();
|
||||||
|
onNavigate(item.href);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
onClick={handleClick}
|
||||||
|
data-testid={`bl-nav-${item.href.replace(/\//g, '-').replace(/^-/, '')}`}
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: collapsed ? '10px 0' : '10px 12px',
|
||||||
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: active ? 600 : 400,
|
||||||
|
color: active
|
||||||
|
? 'var(--bl-shell-nav-active-text, var(--color-foreground, #111827))'
|
||||||
|
: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
|
||||||
|
background: active
|
||||||
|
? 'var(--bl-shell-nav-active-bg, var(--color-muted, #f3f4f6))'
|
||||||
|
: 'transparent',
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'background 0.15s, color 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon && (
|
||||||
|
<span style={{ fontSize: 16, width: 20, textAlign: 'center', flexShrink: 0 }}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!collapsed && <span style={{ flex: 1 }}>{item.label}</span>}
|
||||||
|
{!collapsed && item.badge !== undefined && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 10,
|
||||||
|
background: 'var(--bl-shell-badge-bg, var(--color-primary, #2563eb))',
|
||||||
|
color: 'var(--bl-shell-badge-text, #fff)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
productName,
|
||||||
|
logo,
|
||||||
|
version,
|
||||||
|
nav,
|
||||||
|
pathname,
|
||||||
|
features = {},
|
||||||
|
onNavigate,
|
||||||
|
footer,
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapse,
|
||||||
|
}: SidebarProps): ReactNode {
|
||||||
|
const sections: NavSection[] = isNavSections(nav) ? nav : [{ items: nav }];
|
||||||
|
|
||||||
|
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/');
|
||||||
|
|
||||||
|
// Add built-in settings nav if enabled and not already present
|
||||||
|
const hasSettings = features.settings !== false;
|
||||||
|
const allItems = sections.flatMap(s => s.items);
|
||||||
|
const settingsExists = allItems.some(i => i.href === '/settings');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
data-testid="bl-shell-sidebar"
|
||||||
|
style={{
|
||||||
|
width: collapsed ? 64 : 220,
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'var(--bl-shell-sidebar-bg, var(--color-surface, #fff))',
|
||||||
|
borderRight: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
padding: '16px 0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'width 0.2s ease',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: collapsed ? '0 8px 16px' : '0 16px 16px',
|
||||||
|
borderBottom: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
marginBottom: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||||
|
{logo ? (
|
||||||
|
<div data-testid="bl-shell-logo">{logo}</div>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
data-testid="bl-shell-product-name"
|
||||||
|
style={{
|
||||||
|
fontSize: collapsed ? 14 : 18,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? productName.charAt(0) : productName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onToggleCollapse && (
|
||||||
|
<button
|
||||||
|
data-testid="bl-shell-collapse-toggle"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 4,
|
||||||
|
fontSize: 16,
|
||||||
|
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
|
||||||
|
}}
|
||||||
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
>
|
||||||
|
{collapsed ? '▸' : '◂'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav
|
||||||
|
data-testid="bl-shell-nav"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
padding: collapsed ? '0 8px' : '0 8px',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sections.map((section, si) => (
|
||||||
|
<div key={si}>
|
||||||
|
{section.title && !collapsed && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 12px 4px',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{section.items.map(item => (
|
||||||
|
<NavLink
|
||||||
|
key={item.href}
|
||||||
|
item={item}
|
||||||
|
active={isActive(item.href)}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Built-in settings link */}
|
||||||
|
{hasSettings && !settingsExists && (
|
||||||
|
<div style={{ marginTop: 'auto' }}>
|
||||||
|
<NavLink
|
||||||
|
item={{ href: '/settings', label: 'Settings', icon: '⚙' }}
|
||||||
|
active={isActive('/settings')}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
data-testid="bl-shell-sidebar-footer"
|
||||||
|
style={{
|
||||||
|
marginTop: 'auto',
|
||||||
|
padding: collapsed ? '12px 8px' : '12px 16px',
|
||||||
|
borderTop: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
|
||||||
|
textAlign: collapsed ? 'center' : 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{footer
|
||||||
|
? footer
|
||||||
|
: version
|
||||||
|
? collapsed
|
||||||
|
? `v${version}`
|
||||||
|
: `${productName} v${version}`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
packages/dashboard-shell/src/TopBar.tsx
Normal file
244
packages/dashboard-shell/src/TopBar.tsx
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import type { TopBarProps } from './types.js';
|
||||||
|
|
||||||
|
export function TopBar({
|
||||||
|
user,
|
||||||
|
features = {},
|
||||||
|
onSignOut,
|
||||||
|
onNavigate,
|
||||||
|
actions,
|
||||||
|
onToggleSidebar,
|
||||||
|
}: TopBarProps): ReactNode {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleNav = (href: string) => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
if (onNavigate) onNavigate(href);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initials = user
|
||||||
|
? user.name
|
||||||
|
.split(' ')
|
||||||
|
.map(w => w[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
: '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
data-testid="bl-shell-topbar"
|
||||||
|
style={{
|
||||||
|
height: 56,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 24px',
|
||||||
|
borderBottom: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
background: 'var(--bl-shell-topbar-bg, var(--color-surface, #fff))',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left: mobile hamburger */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
{onToggleSidebar && (
|
||||||
|
<button
|
||||||
|
data-testid="bl-shell-hamburger"
|
||||||
|
onClick={onToggleSidebar}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 20,
|
||||||
|
padding: 4,
|
||||||
|
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
|
||||||
|
}}
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: actions + user menu */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
{actions}
|
||||||
|
|
||||||
|
{features.notifications && (
|
||||||
|
<button
|
||||||
|
data-testid="bl-shell-notifications"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 18,
|
||||||
|
padding: 4,
|
||||||
|
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
|
||||||
|
}}
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
🔔
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
data-testid="bl-shell-user-menu-trigger"
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt={user.name}
|
||||||
|
style={{ width: 32, height: 32, borderRadius: '50%' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
data-testid="bl-shell-user-avatar"
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
||||||
|
color: '#fff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 10 }}>▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
data-testid="bl-shell-user-menu"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: '100%',
|
||||||
|
marginTop: 4,
|
||||||
|
minWidth: 180,
|
||||||
|
background: 'var(--bl-shell-topbar-bg, var(--color-surface, #fff))',
|
||||||
|
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||||
|
zIndex: 50,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600 }}>{user.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-muted-foreground, #6b7280)' }}>
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{features.profile !== false && (
|
||||||
|
<a
|
||||||
|
data-testid="bl-shell-menu-profile"
|
||||||
|
href="/profile"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNav('/profile');
|
||||||
|
}}
|
||||||
|
style={menuItemStyle}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{features.billing && (
|
||||||
|
<a
|
||||||
|
data-testid="bl-shell-menu-billing"
|
||||||
|
href="/billing"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNav('/billing');
|
||||||
|
}}
|
||||||
|
style={menuItemStyle}
|
||||||
|
>
|
||||||
|
Billing
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{features.settings !== false && (
|
||||||
|
<a
|
||||||
|
data-testid="bl-shell-menu-settings"
|
||||||
|
href="/settings"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNav('/settings');
|
||||||
|
}}
|
||||||
|
style={menuItemStyle}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onSignOut && (
|
||||||
|
<button
|
||||||
|
data-testid="bl-shell-menu-signout"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
onSignOut();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...menuItemStyle,
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
borderTop: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
color: 'var(--color-destructive, #dc2626)',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
borderTopStyle: 'solid',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: 'var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItemStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
padding: '10px 16px',
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-foreground, #111827)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
376
packages/dashboard-shell/src/__tests__/dashboard-shell.test.tsx
Normal file
376
packages/dashboard-shell/src/__tests__/dashboard-shell.test.tsx
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { DashboardShell } from '../DashboardShell.js';
|
||||||
|
import { Sidebar } from '../Sidebar.js';
|
||||||
|
import { TopBar } from '../TopBar.js';
|
||||||
|
import { ProfilePage } from '../ProfilePage.js';
|
||||||
|
import { BillingPage } from '../BillingPage.js';
|
||||||
|
import { SettingsPage } from '../SettingsPage.js';
|
||||||
|
import type { NavItem, NavSection, ShellUser } from '../types.js';
|
||||||
|
|
||||||
|
const NAV: NavItem[] = [
|
||||||
|
{ href: '/dashboard', label: 'Dashboard', icon: '◈' },
|
||||||
|
{ href: '/tasks', label: 'Tasks', icon: '✓' },
|
||||||
|
{ href: '/settings', label: 'Settings', icon: '⚙' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const USER: ShellUser = {
|
||||||
|
id: 'u1',
|
||||||
|
name: 'Alice Smith',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
role: 'admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── DashboardShell ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DashboardShell', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders sidebar, topbar, and content', () => {
|
||||||
|
render(
|
||||||
|
<DashboardShell productName="TestApp" nav={NAV} pathname="/dashboard">
|
||||||
|
<div>Page content</div>
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('bl-dashboard-shell')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-shell-sidebar')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-shell-topbar')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-shell-main')).toBeDefined();
|
||||||
|
expect(screen.getByText('Page content')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes product name to sidebar', () => {
|
||||||
|
render(
|
||||||
|
<DashboardShell productName="MyProduct" nav={NAV} pathname="/">
|
||||||
|
<div />
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('MyProduct');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes user to topbar', () => {
|
||||||
|
render(
|
||||||
|
<DashboardShell productName="TestApp" nav={NAV} pathname="/" user={USER}>
|
||||||
|
<div />
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Alice Smith')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSignOut when sign out clicked', () => {
|
||||||
|
const onSignOut = vi.fn();
|
||||||
|
render(
|
||||||
|
<DashboardShell
|
||||||
|
productName="TestApp"
|
||||||
|
nav={NAV}
|
||||||
|
pathname="/"
|
||||||
|
user={USER}
|
||||||
|
onSignOut={onSignOut}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
// Open user menu
|
||||||
|
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
|
||||||
|
fireEvent.click(screen.getByTestId('bl-shell-menu-signout'));
|
||||||
|
expect(onSignOut).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles sidebar collapse', () => {
|
||||||
|
render(
|
||||||
|
<DashboardShell productName="TestApp" nav={NAV} pathname="/dashboard">
|
||||||
|
<div />
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
const toggle = screen.getByTestId('bl-shell-collapse-toggle');
|
||||||
|
// Initially expanded — product name shows full text
|
||||||
|
expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('TestApp');
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
// Collapsed — shows first letter
|
||||||
|
expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('T');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Sidebar', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders nav items', () => {
|
||||||
|
render(<Sidebar productName="TestApp" nav={NAV} pathname="/dashboard" />);
|
||||||
|
expect(screen.getByText('Dashboard')).toBeDefined();
|
||||||
|
expect(screen.getByText('Tasks')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights active nav item', () => {
|
||||||
|
render(<Sidebar productName="TestApp" nav={NAV} pathname="/dashboard" />);
|
||||||
|
const dashLink = screen.getByTestId('bl-nav-dashboard');
|
||||||
|
expect(dashLink.style.fontWeight).toBe('600');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onNavigate when item clicked', () => {
|
||||||
|
const onNavigate = vi.fn();
|
||||||
|
render(<Sidebar productName="TestApp" nav={NAV} pathname="/" onNavigate={onNavigate} />);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-nav-tasks'));
|
||||||
|
expect(onNavigate).toHaveBeenCalledWith('/tasks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports NavSection format', () => {
|
||||||
|
const sections: NavSection[] = [
|
||||||
|
{ title: 'Main', items: [{ href: '/home', label: 'Home' }] },
|
||||||
|
{ title: 'Admin', items: [{ href: '/admin', label: 'Admin Panel' }] },
|
||||||
|
];
|
||||||
|
render(<Sidebar productName="TestApp" nav={sections} pathname="/" />);
|
||||||
|
expect(screen.getByText('Main')).toBeDefined();
|
||||||
|
expect(screen.getByText('Admin')).toBeDefined();
|
||||||
|
expect(screen.getByText('Home')).toBeDefined();
|
||||||
|
expect(screen.getByText('Admin Panel')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides hidden nav items', () => {
|
||||||
|
const items: NavItem[] = [
|
||||||
|
{ href: '/visible', label: 'Visible' },
|
||||||
|
{ href: '/hidden', label: 'Hidden', hidden: true },
|
||||||
|
];
|
||||||
|
render(<Sidebar productName="TestApp" nav={items} pathname="/" />);
|
||||||
|
expect(screen.getByText('Visible')).toBeDefined();
|
||||||
|
expect(screen.queryByText('Hidden')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows badge on nav item', () => {
|
||||||
|
const items: NavItem[] = [{ href: '/inbox', label: 'Inbox', badge: 5 }];
|
||||||
|
render(<Sidebar productName="TestApp" nav={items} pathname="/" />);
|
||||||
|
expect(screen.getByText('5')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders version in footer', () => {
|
||||||
|
render(<Sidebar productName="TestApp" nav={NAV} pathname="/" version="1.2.3" />);
|
||||||
|
expect(screen.getByTestId('bl-shell-sidebar-footer').textContent).toContain('v1.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom footer', () => {
|
||||||
|
render(<Sidebar productName="TestApp" nav={NAV} pathname="/" footer={<span>Custom</span>} />);
|
||||||
|
expect(screen.getByText('Custom')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders logo instead of product name', () => {
|
||||||
|
render(
|
||||||
|
<Sidebar
|
||||||
|
productName="TestApp"
|
||||||
|
nav={NAV}
|
||||||
|
pathname="/"
|
||||||
|
logo={<span data-testid="logo">Logo</span>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('logo')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-adds settings link when not present', () => {
|
||||||
|
const items: NavItem[] = [{ href: '/dashboard', label: 'Dashboard' }];
|
||||||
|
render(
|
||||||
|
<Sidebar productName="TestApp" nav={items} pathname="/" features={{ settings: true }} />
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('bl-nav-settings')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate settings link when already present', () => {
|
||||||
|
render(<Sidebar productName="TestApp" nav={NAV} pathname="/" />);
|
||||||
|
const settingsLinks = screen.getAllByText('Settings');
|
||||||
|
expect(settingsLinks.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TopBar ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('TopBar', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders user name', () => {
|
||||||
|
render(<TopBar user={USER} />);
|
||||||
|
expect(screen.getByText('Alice Smith')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders initials avatar when no avatarUrl', () => {
|
||||||
|
render(<TopBar user={USER} />);
|
||||||
|
expect(screen.getByTestId('bl-shell-user-avatar').textContent).toBe('AS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens and closes user menu', () => {
|
||||||
|
render(<TopBar user={USER} />);
|
||||||
|
expect(screen.queryByTestId('bl-shell-user-menu')).toBeNull();
|
||||||
|
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
|
||||||
|
expect(screen.getByTestId('bl-shell-user-menu')).toBeDefined();
|
||||||
|
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
|
||||||
|
expect(screen.queryByTestId('bl-shell-user-menu')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows profile link in menu by default', () => {
|
||||||
|
render(<TopBar user={USER} />);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
|
||||||
|
expect(screen.getByTestId('bl-shell-menu-profile')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows billing link when feature enabled', () => {
|
||||||
|
render(<TopBar user={USER} features={{ billing: true }} />);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
|
||||||
|
expect(screen.getByTestId('bl-shell-menu-billing')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides billing link when feature disabled', () => {
|
||||||
|
render(<TopBar user={USER} features={{ billing: false }} />);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
|
||||||
|
expect(screen.queryByTestId('bl-shell-menu-billing')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows notifications bell when enabled', () => {
|
||||||
|
render(<TopBar user={USER} features={{ notifications: true }} />);
|
||||||
|
expect(screen.getByTestId('bl-shell-notifications')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSignOut', () => {
|
||||||
|
const onSignOut = vi.fn();
|
||||||
|
render(<TopBar user={USER} onSignOut={onSignOut} />);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
|
||||||
|
fireEvent.click(screen.getByTestId('bl-shell-menu-signout'));
|
||||||
|
expect(onSignOut).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onNavigate for menu items', () => {
|
||||||
|
const onNavigate = vi.fn();
|
||||||
|
render(<TopBar user={USER} onNavigate={onNavigate} />);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger'));
|
||||||
|
fireEvent.click(screen.getByTestId('bl-shell-menu-profile'));
|
||||||
|
expect(onNavigate).toHaveBeenCalledWith('/profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom actions', () => {
|
||||||
|
render(<TopBar user={USER} actions={<button data-testid="custom-action">Action</button>} />);
|
||||||
|
expect(screen.getByTestId('custom-action')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows hamburger when onToggleSidebar provided', () => {
|
||||||
|
const toggle = vi.fn();
|
||||||
|
render(<TopBar user={USER} onToggleSidebar={toggle} />);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-shell-hamburger'));
|
||||||
|
expect(toggle).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ProfilePage ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProfilePage', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders user info', () => {
|
||||||
|
render(<ProfilePage user={USER} />);
|
||||||
|
expect(screen.getByTestId('bl-shell-profile-page')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-profile-avatar')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-fills form fields', () => {
|
||||||
|
render(<ProfilePage user={USER} />);
|
||||||
|
const nameInput = screen.getByTestId('bl-profile-name') as unknown as { value: string };
|
||||||
|
const emailInput = screen.getByTestId('bl-profile-email') as unknown as { value: string };
|
||||||
|
expect(nameInput.value).toBe('Alice Smith');
|
||||||
|
expect(emailInput.value).toBe('alice@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onUpdateProfile with form data', () => {
|
||||||
|
const onUpdate = vi.fn();
|
||||||
|
render(<ProfilePage user={USER} onUpdateProfile={onUpdate} />);
|
||||||
|
fireEvent.change(screen.getByTestId('bl-profile-name'), { target: { value: 'Bob Jones' } });
|
||||||
|
fireEvent.submit(screen.getByTestId('bl-profile-submit').closest('form')!);
|
||||||
|
expect(onUpdate).toHaveBeenCalledWith({ name: 'Bob Jones', email: 'alice@example.com' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state', () => {
|
||||||
|
render(<ProfilePage user={USER} onUpdateProfile={vi.fn()} isLoading />);
|
||||||
|
expect(screen.getByText('Saving...')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error and success messages', () => {
|
||||||
|
const { rerender } = render(<ProfilePage user={USER} error="Failed" />);
|
||||||
|
expect(screen.getByTestId('bl-profile-error')).toBeDefined();
|
||||||
|
|
||||||
|
rerender(<ProfilePage user={USER} success="Saved" />);
|
||||||
|
expect(screen.getByTestId('bl-profile-success')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows role when present', () => {
|
||||||
|
render(<ProfilePage user={USER} />);
|
||||||
|
expect(screen.getByText('Role: admin')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── BillingPage ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('BillingPage', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders current plan', () => {
|
||||||
|
render(<BillingPage currentPlan="Pro" status="active" />);
|
||||||
|
expect(screen.getByTestId('bl-shell-billing-page')).toBeDefined();
|
||||||
|
expect(screen.getByText('Pro')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows status badge', () => {
|
||||||
|
render(<BillingPage currentPlan="Free" status="trialing" />);
|
||||||
|
expect(screen.getByTestId('bl-billing-status').textContent).toBe('trialing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows trial end date', () => {
|
||||||
|
render(<BillingPage trialEndsAt="2026-04-01" />);
|
||||||
|
expect(screen.getByTestId('bl-billing-trial').textContent).toContain('2026-04-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onManageBilling', () => {
|
||||||
|
const onManage = vi.fn();
|
||||||
|
render(<BillingPage onManageBilling={onManage} />);
|
||||||
|
fireEvent.click(screen.getByTestId('bl-billing-manage'));
|
||||||
|
expect(onManage).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders plan comparison grid', () => {
|
||||||
|
const plans = [
|
||||||
|
{ name: 'Free', price: '$0/mo', features: ['Basic features'], current: true },
|
||||||
|
{ name: 'Pro', price: '$9/mo', features: ['All features', 'Priority support'] },
|
||||||
|
];
|
||||||
|
render(<BillingPage plans={plans} />);
|
||||||
|
expect(screen.getByTestId('bl-billing-plans')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-billing-plan-free')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-billing-plan-pro')).toBeDefined();
|
||||||
|
expect(screen.getByText('$9/mo')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to Free plan when not specified', () => {
|
||||||
|
render(<BillingPage />);
|
||||||
|
expect(screen.getByText('Free')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SettingsPage ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('SettingsPage', () => {
|
||||||
|
beforeEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders empty state when no sections', () => {
|
||||||
|
render(<SettingsPage productName="TestApp" />);
|
||||||
|
expect(screen.getByTestId('bl-shell-settings-page')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-settings-empty')).toBeDefined();
|
||||||
|
expect(screen.getByText('No settings configured for TestApp.')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders sections', () => {
|
||||||
|
const sections = [
|
||||||
|
{ title: 'Notifications', description: 'Manage alerts', content: <div>Toggle</div> },
|
||||||
|
{ title: 'Theme', content: <div>Dark mode</div> },
|
||||||
|
];
|
||||||
|
render(<SettingsPage productName="TestApp" sections={sections} />);
|
||||||
|
expect(screen.getByTestId('bl-settings-section-0')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('bl-settings-section-1')).toBeDefined();
|
||||||
|
expect(screen.getByText('Notifications')).toBeDefined();
|
||||||
|
expect(screen.getByText('Manage alerts')).toBeDefined();
|
||||||
|
expect(screen.getByText('Toggle')).toBeDefined();
|
||||||
|
expect(screen.getByText('Dark mode')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
30
packages/dashboard-shell/src/index.ts
Normal file
30
packages/dashboard-shell/src/index.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* @bytelyst/dashboard-shell
|
||||||
|
*
|
||||||
|
* Configurable Next.js dashboard layout with sidebar, top bar,
|
||||||
|
* and built-in pages for profile, billing, and settings.
|
||||||
|
*
|
||||||
|
* All components read CSS custom properties (--bl-shell-*, --color-*)
|
||||||
|
* with sensible fallback defaults.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { DashboardShell } from './DashboardShell.js';
|
||||||
|
export { Sidebar } from './Sidebar.js';
|
||||||
|
export { TopBar } from './TopBar.js';
|
||||||
|
export { ProfilePage } from './ProfilePage.js';
|
||||||
|
export { BillingPage } from './BillingPage.js';
|
||||||
|
export { SettingsPage } from './SettingsPage.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DashboardShellProps,
|
||||||
|
SidebarProps,
|
||||||
|
TopBarProps,
|
||||||
|
NavItem,
|
||||||
|
NavSection,
|
||||||
|
ShellUser,
|
||||||
|
ShellFeatures,
|
||||||
|
ProfilePageProps,
|
||||||
|
BillingPageProps,
|
||||||
|
SettingsPageProps,
|
||||||
|
SettingsSection,
|
||||||
|
} from './types.js';
|
||||||
131
packages/dashboard-shell/src/types.ts
Normal file
131
packages/dashboard-shell/src/types.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
// ── Navigation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
badge?: string | number;
|
||||||
|
/** Hide this item from nav (useful for feature-flag gating) */
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavSection {
|
||||||
|
title?: string;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ShellUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Features ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ShellFeatures {
|
||||||
|
/** Show profile page link in user menu (default: true) */
|
||||||
|
profile?: boolean;
|
||||||
|
/** Show billing page link in user menu (default: false) */
|
||||||
|
billing?: boolean;
|
||||||
|
/** Show settings page link in sidebar (default: true) */
|
||||||
|
settings?: boolean;
|
||||||
|
/** Show notifications icon in top bar (default: false) */
|
||||||
|
notifications?: boolean;
|
||||||
|
/** Show dark/light theme toggle (default: false) */
|
||||||
|
themeToggle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shell Config ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DashboardShellProps {
|
||||||
|
/** Product display name shown in sidebar header */
|
||||||
|
productName: string;
|
||||||
|
/** Product logo element (replaces text name if provided) */
|
||||||
|
logo?: ReactNode;
|
||||||
|
/** Product version shown in sidebar footer */
|
||||||
|
version?: string;
|
||||||
|
/** Navigation items or sections */
|
||||||
|
nav: NavItem[] | NavSection[];
|
||||||
|
/** Current pathname for active state (if not using internal detection) */
|
||||||
|
pathname?: string;
|
||||||
|
/** Currently logged-in user */
|
||||||
|
user?: ShellUser;
|
||||||
|
/** Feature toggles for built-in pages */
|
||||||
|
features?: ShellFeatures;
|
||||||
|
/** Called when user clicks Sign Out */
|
||||||
|
onSignOut?: () => void;
|
||||||
|
/** Called when a nav item is clicked (for SPA routers) */
|
||||||
|
onNavigate?: (href: string) => void;
|
||||||
|
/** Sidebar footer content (replaces default) */
|
||||||
|
sidebarFooter?: ReactNode;
|
||||||
|
/** Content to render in the top bar (right side) */
|
||||||
|
topBarActions?: ReactNode;
|
||||||
|
/** Dashboard page content */
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sidebar Props ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
productName: string;
|
||||||
|
logo?: ReactNode;
|
||||||
|
version?: string;
|
||||||
|
nav: NavItem[] | NavSection[];
|
||||||
|
pathname: string;
|
||||||
|
features?: ShellFeatures;
|
||||||
|
onNavigate?: (href: string) => void;
|
||||||
|
footer?: ReactNode;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Top Bar Props ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TopBarProps {
|
||||||
|
user?: ShellUser;
|
||||||
|
features?: ShellFeatures;
|
||||||
|
onSignOut?: () => void;
|
||||||
|
onNavigate?: (href: string) => void;
|
||||||
|
actions?: ReactNode;
|
||||||
|
onToggleSidebar?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Built-in Page Props ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ProfilePageProps {
|
||||||
|
user: ShellUser;
|
||||||
|
onUpdateProfile?: (data: { name: string; email: string }) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BillingPageProps {
|
||||||
|
currentPlan?: string;
|
||||||
|
status?: 'active' | 'trialing' | 'past_due' | 'canceled';
|
||||||
|
trialEndsAt?: string;
|
||||||
|
onManageBilling?: () => void;
|
||||||
|
plans?: Array<{
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
features: string[];
|
||||||
|
current?: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsPageProps {
|
||||||
|
productName: string;
|
||||||
|
sections?: SettingsSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsSection {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
content: ReactNode;
|
||||||
|
}
|
||||||
11
packages/dashboard-shell/tsconfig.json
Normal file
11
packages/dashboard-shell/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["dist", "src/**/*.test.*"]
|
||||||
|
}
|
||||||
7
packages/dashboard-shell/vitest.config.ts
Normal file
7
packages/dashboard-shell/vitest.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user