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
190 lines
5.7 KiB
TypeScript
190 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|