Initial import: property management SaaS + security hardening + admin dashboard

Property Management Network — Next.js 16 (App Router), Better Auth,
Drizzle ORM over PostgreSQL, Stripe, OpenAI, Resend.

Includes:
- Security hardening: access-control/IDOR fixes, TLS-by-default DB layer,
  constant-time cron auth, strict security headers, atomic AI quota gating,
  HTML/email output encoding, demo-backdoor disabled in production.
- Superadmin dashboard at /admin (overview/MRR, server-paginated users with
  ban/impersonate/plan/delete, billing, platform activity + admin audit log,
  AI usage, system health) via the Better Auth admin plugin.
- Seed/migration utility scripts under scripts/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-06-23 20:36:07 -04:00
co-authored by Claude Opus 4.8
commit 857b9a7811
291 changed files with 38996 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { ChevronRight, Home } from "lucide-react"
const SEGMENT_LABELS: Record<string, string> = {
dashboard: "Dashboard",
properties: "Properties",
tenants: "Tenants",
rent: "Rent Tracker",
maintenance: "Maintenance",
leases: "Leases",
expenses: "Expenses",
reports: "Reports",
vendors: "Vendors",
inspections: "Inspections",
ai: "AI Assistant",
settings: "Settings",
profile: "Profile",
billing: "Billing",
demo: "Demo Data",
new: "New",
edit: "Edit",
generate: "Generate",
documents: "Documents",
}
function isUUID(s: string) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)
}
export function Breadcrumbs() {
const pathname = usePathname()
const segments = pathname.split("/").filter(Boolean)
// Don't show on top-level pages (only 1 segment)
if (segments.length <= 1) return null
const crumbs: { label: string; href: string }[] = []
let acc = ""
for (const seg of segments) {
acc += `/${seg}`
if (isUUID(seg)) {
crumbs.push({ label: "Detail", href: acc })
} else {
crumbs.push({ label: SEGMENT_LABELS[seg] ?? seg, href: acc })
}
}
return (
<nav className="mb-4 flex items-center gap-1.5 text-xs text-white/30">
<Link href="/dashboard" className="flex items-center gap-1 transition hover:text-white/60">
<Home className="h-3 w-3" />
</Link>
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1
return (
<span key={crumb.href} className="flex items-center gap-1.5">
<ChevronRight className="h-3 w-3 text-white/15" />
{isLast ? (
<span className="font-medium text-white/60">{crumb.label}</span>
) : (
<Link href={crumb.href} className="transition hover:text-white/60">
{crumb.label}
</Link>
)}
</span>
)
})}
</nav>
)
}
+364
View File
@@ -0,0 +1,364 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import {
Search, LayoutDashboard, Building2, Users, CreditCard,
Wrench, FileText, Receipt, BarChart3, Hammer, ClipboardList,
Bot, Settings, Zap, Plus, X, ArrowRight, Loader2,
} from "lucide-react"
import { cn } from "@/lib/utils"
const COMMANDS = [
{
group: "Navigate",
items: [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard, keywords: "home overview" },
{ label: "Properties", href: "/properties", icon: Building2, keywords: "buildings units" },
{ label: "Tenants", href: "/tenants", icon: Users, keywords: "renters residents" },
{ label: "Rent Tracker", href: "/rent", icon: CreditCard, keywords: "payments collect" },
{ label: "Maintenance", href: "/maintenance", icon: Wrench, keywords: "repair fix" },
{ label: "Leases", href: "/leases", icon: FileText, keywords: "contracts agreements" },
{ label: "Expenses", href: "/expenses", icon: Receipt, keywords: "costs bills" },
{ label: "Reports", href: "/reports", icon: BarChart3, keywords: "analytics profit" },
{ label: "Vendors", href: "/vendors", icon: Hammer, keywords: "contractors workers" },
{ label: "Inspections", href: "/inspections", icon: ClipboardList, keywords: "checklist condition" },
{ label: "AI Assistant", href: "/ai", icon: Bot, keywords: "ask chat" },
{ label: "Settings", href: "/settings/profile", icon: Settings, keywords: "profile account" },
],
},
{
group: "Quick Actions",
items: [
{ label: "Add Property", href: "/properties/new", icon: Building2, keywords: "new create" },
{ label: "Add Tenant", href: "/tenants/new", icon: Users, keywords: "new create" },
{ label: "Record Payment", href: "/rent/new", icon: CreditCard, keywords: "new create" },
{ label: "New Maintenance Request",href: "/maintenance/new", icon: Wrench, keywords: "new create" },
{ label: "New Lease", href: "/leases/new", icon: FileText, keywords: "new create" },
{ label: "Add Expense", href: "/expenses/new", icon: Receipt, keywords: "new create" },
{ label: "Generate Rent", href: "/rent/generate", icon: Zap, keywords: "bulk all" },
],
},
]
type CommandItem = (typeof COMMANDS)[0]["items"][0]
interface SearchResults {
tenants: { id: string; first_name: string; last_name: string; email?: string }[]
properties: { id: string; name: string; address_line1?: string; city?: string }[]
maintenance: { id: string; title: string; status: string; priority: string }[]
}
export function CommandPalette() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [cursor, setCursor] = useState(0)
const [liveResults, setLiveResults] = useState<SearchResults | null>(null)
const [searching, setSearching] = useState(false)
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Open on Ctrl+K / Cmd+K
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault()
setOpen((v) => !v)
}
if (e.key === "Escape") setOpen(false)
}
document.addEventListener("keydown", onKey)
return () => document.removeEventListener("keydown", onKey)
}, [])
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 50)
setQuery("")
setCursor(0)
setLiveResults(null)
}
}, [open])
// Live search debounce
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
if (query.length < 2) { setLiveResults(null); return }
setSearching(true)
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
if (res.ok) setLiveResults(await res.json())
} finally {
setSearching(false)
}
}, 300)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [query])
const filtered: { group: string; items: CommandItem[] }[] = COMMANDS.map((g) => ({
group: g.group,
items: g.items.filter((item) => {
if (!query) return true
const q = query.toLowerCase()
return item.label.toLowerCase().includes(q) || item.keywords.includes(q)
}),
})).filter((g) => g.items.length > 0)
const flat: CommandItem[] = filtered.flatMap((g) => g.items)
function go(href: string) {
router.push(href)
setOpen(false)
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault()
setCursor((v) => Math.min(v + 1, flat.length - 1))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setCursor((v) => Math.max(v - 1, 0))
} else if (e.key === "Enter") {
if (flat[cursor]) go(flat[cursor].href)
}
}
// Scroll active item into view
useEffect(() => {
const el = listRef.current?.querySelector(`[data-idx="${cursor}"]`) as HTMLElement
el?.scrollIntoView({ block: "nearest" })
}, [cursor])
if (!open) return null
let globalIdx = 0
return (
<div className="fixed inset-0 z-[200] flex items-start justify-center pt-[15vh] px-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
{/* Panel */}
<div className="relative w-full max-w-xl overflow-hidden rounded-2xl border border-white/[0.1] bg-[#16161f] shadow-2xl shadow-black/80">
{/* Search input */}
<div className="flex items-center gap-3 border-b border-white/[0.06] px-4 py-3.5">
<Search className="h-4 w-4 shrink-0 text-white/30" />
<input
ref={inputRef}
value={query}
onChange={(e) => { setQuery(e.target.value); setCursor(0) }}
onKeyDown={onKeyDown}
placeholder="Search pages, actions…"
className="flex-1 bg-transparent text-sm text-white placeholder-white/30 outline-none"
/>
{query && (
<button onClick={() => setQuery("")} className="text-white/30 hover:text-white transition">
<X className="h-3.5 w-3.5" />
</button>
)}
<kbd className="hidden sm:flex items-center gap-1 rounded-md border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-mono text-white/30">
esc
</kbd>
</div>
{/* Results */}
<div ref={listRef} className="max-h-96 overflow-y-auto py-2">
{/* Live data results when query length >= 2 */}
{query.length >= 2 && (
<>
{searching && (
<div className="flex items-center gap-2 px-4 py-2 text-xs text-white/30">
<Loader2 className="h-3 w-3 animate-spin" /> Searching
</div>
)}
{liveResults && (
<>
{liveResults.tenants.length > 0 && (
<div>
<p className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/20">Tenants</p>
{liveResults.tenants.map((t) => {
const idx = globalIdx++
const active = cursor === idx
return (
<button
key={t.id}
data-idx={idx}
onClick={() => go(`/tenants/${t.id}`)}
onMouseEnter={() => setCursor(idx)}
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left",
active ? "bg-indigo-600/20 text-white" : "text-white/60 hover:bg-white/[0.04] hover:text-white"
)}
>
<div className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border transition-colors",
active ? "border-indigo-500/30 bg-indigo-500/20 text-indigo-400" : "border-white/[0.06] bg-white/[0.03] text-white/30"
)}>
<Users className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{t.first_name} {t.last_name}</p>
{t.email && <p className="text-xs text-white/30 truncate">{t.email}</p>}
</div>
{active && <ArrowRight className="h-3.5 w-3.5 text-indigo-400 shrink-0" />}
</button>
)
})}
</div>
)}
{liveResults.properties.length > 0 && (
<div>
<p className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/20">Properties</p>
{liveResults.properties.map((p) => {
const idx = globalIdx++
const active = cursor === idx
return (
<button
key={p.id}
data-idx={idx}
onClick={() => go(`/properties/${p.id}`)}
onMouseEnter={() => setCursor(idx)}
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left",
active ? "bg-indigo-600/20 text-white" : "text-white/60 hover:bg-white/[0.04] hover:text-white"
)}
>
<div className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border transition-colors",
active ? "border-indigo-500/30 bg-indigo-500/20 text-indigo-400" : "border-white/[0.06] bg-white/[0.03] text-white/30"
)}>
<Building2 className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{p.name}</p>
{p.city && <p className="text-xs text-white/30 truncate">{p.address_line1 ? `${p.address_line1}, ` : ""}{p.city}</p>}
</div>
{active && <ArrowRight className="h-3.5 w-3.5 text-indigo-400 shrink-0" />}
</button>
)
})}
</div>
)}
{liveResults.maintenance.length > 0 && (
<div>
<p className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/20">Maintenance</p>
{liveResults.maintenance.map((m) => {
const idx = globalIdx++
const active = cursor === idx
return (
<button
key={m.id}
data-idx={idx}
onClick={() => go(`/maintenance/${m.id}`)}
onMouseEnter={() => setCursor(idx)}
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left",
active ? "bg-indigo-600/20 text-white" : "text-white/60 hover:bg-white/[0.04] hover:text-white"
)}
>
<div className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border transition-colors",
active ? "border-indigo-500/30 bg-indigo-500/20 text-indigo-400" : "border-white/[0.06] bg-white/[0.03] text-white/30"
)}>
<Wrench className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{m.title}</p>
<p className="text-xs text-white/30 capitalize">{m.priority} · {m.status.replace("_", " ")}</p>
</div>
{active && <ArrowRight className="h-3.5 w-3.5 text-indigo-400 shrink-0" />}
</button>
)
})}
</div>
)}
{!searching && liveResults.tenants.length === 0 && liveResults.properties.length === 0 && liveResults.maintenance.length === 0 && filtered.length === 0 && (
<p className="py-8 text-center text-sm text-white/30">No results for &ldquo;{query}&rdquo;</p>
)}
</>
)}
{/* Divider before nav items if there are live results */}
{liveResults && (liveResults.tenants.length > 0 || liveResults.properties.length > 0 || liveResults.maintenance.length > 0) && filtered.length > 0 && (
<div className="my-1 border-t border-white/[0.06]" />
)}
</>
)}
{/* Static command items */}
{filtered.length === 0 && query.length < 2 ? (
<p className="py-10 text-center text-sm text-white/30">Type to search</p>
) : (
filtered.map((group) => (
<div key={group.group}>
<p className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/20">
{group.group}
</p>
{group.items.map((item) => {
const idx = globalIdx++
const Icon = item.icon
const active = cursor === idx
return (
<button
key={item.href}
data-idx={idx}
onClick={() => go(item.href)}
onMouseEnter={() => setCursor(idx)}
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left",
active ? "bg-indigo-600/20 text-white" : "text-white/60 hover:bg-white/[0.04] hover:text-white"
)}
>
<div className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border transition-colors",
active ? "border-indigo-500/30 bg-indigo-500/20 text-indigo-400" : "border-white/[0.06] bg-white/[0.03] text-white/30"
)}>
<Icon className="h-3.5 w-3.5" />
</div>
<span className="flex-1 font-medium">{item.label}</span>
{active && <ArrowRight className="h-3.5 w-3.5 text-indigo-400 shrink-0" />}
</button>
)
})}
</div>
))
)}
</div>
{/* Footer */}
<div className="flex items-center gap-4 border-t border-white/[0.06] px-4 py-2.5">
{[["↑↓", "navigate"], ["↵", "open"], ["esc", "close"]].map(([key, label]) => (
<span key={key} className="flex items-center gap-1.5 text-[10px] text-white/25">
<kbd className="rounded border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 font-mono">{key}</kbd>
{label}
</span>
))}
</div>
</div>
</div>
)
}
// Trigger button for the header
export function CommandTrigger() {
return (
<button
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })
document.dispatchEvent(e)
}}
className="hidden sm:flex items-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs text-white/30 transition hover:border-white/[0.15] hover:text-white/60"
>
<Search className="h-3 w-3" />
<span>Search</span>
<kbd className="ml-1 flex items-center gap-0.5 font-mono text-[10px]">
<span></span><span>K</span>
</kbd>
</button>
)
}
@@ -0,0 +1,61 @@
"use client"
import { formatCurrency } from "@/lib/utils"
const CATEGORY_COLORS: Record<string, string> = {
repairs: "bg-amber-500",
utilities: "bg-blue-500",
insurance: "bg-violet-500",
mortgage: "bg-indigo-500",
taxes: "bg-red-500",
management: "bg-emerald-500",
supplies: "bg-cyan-500",
other: "bg-white/20",
}
interface Props {
data: { category: string; amount: number }[]
}
export function ExpenseBreakdownChart({ data }: Props) {
const total = data.reduce((s, d) => s + d.amount, 0)
if (!data.length || total === 0) return null
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<h3 className="text-sm font-semibold text-white mb-4">Expense Breakdown <span className="text-white/30 font-normal">(6 months)</span></h3>
{/* Bar */}
<div className="flex h-3 w-full overflow-hidden rounded-full mb-4">
{data.map((d) => (
<div
key={d.category}
className={`${CATEGORY_COLORS[d.category] ?? CATEGORY_COLORS.other} transition-all`}
style={{ width: `${(d.amount / total) * 100}%` }}
title={`${d.category}: ${formatCurrency(d.amount)}`}
/>
))}
</div>
{/* Legend */}
<div className="space-y-2">
{data.map((d) => (
<div key={d.category} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`h-2.5 w-2.5 rounded-full ${CATEGORY_COLORS[d.category] ?? CATEGORY_COLORS.other}`} />
<span className="text-xs capitalize text-white/60">{d.category}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-white/30">{Math.round((d.amount / total) * 100)}%</span>
<span className="text-xs font-medium text-white tabular-nums">{formatCurrency(d.amount)}</span>
</div>
</div>
))}
<div className="flex items-center justify-between border-t border-white/[0.06] pt-2 mt-2">
<span className="text-xs font-semibold text-white/50">Total</span>
<span className="text-sm font-bold text-white tabular-nums">{formatCurrency(total)}</span>
</div>
</div>
</div>
)
}
+73
View File
@@ -0,0 +1,73 @@
"use client"
import { usePathname } from "next/navigation"
import Link from "next/link"
import { Plus, CreditCard, Wrench, FileText, Receipt, CalendarDays } from "lucide-react"
import { NotificationsBell } from "@/components/dashboard/notifications-bell"
import { CommandTrigger } from "@/components/dashboard/command-palette"
const pageTitles: Record<string, string> = {
"/dashboard": "Dashboard",
"/properties": "Properties",
"/tenants": "Tenants",
"/rent": "Rent Tracker",
"/maintenance": "Maintenance",
"/leases": "Leases",
"/expenses": "Expenses",
"/settings/profile": "Settings",
"/settings/billing": "Billing",
"/settings/demo": "Demo Data",
"/ai": "AI Assistant",
"/reports": "Reports",
"/rent/generate": "Generate Rent",
"/vendors": "Vendors",
"/inspections": "Inspections",
"/calendar": "Calendar",
}
const pageActions: Record<string, { label: string; href: string; icon?: React.ElementType }> = {
"/dashboard": { label: "Add Property", href: "/properties/new" },
"/properties": { label: "Add Property", href: "/properties/new" },
"/tenants": { label: "Add Tenant", href: "/tenants/new" },
"/rent/generate": { label: "Rent Tracker", href: "/rent", icon: CreditCard },
"/rent": { label: "Record Payment", href: "/rent/new", icon: CreditCard },
"/maintenance": { label: "New Request", href: "/maintenance/new", icon: Wrench },
"/leases": { label: "New Lease", href: "/leases/new", icon: FileText },
"/expenses": { label: "Add Expense", href: "/expenses/new", icon: Receipt },
}
export function Header() {
const pathname = usePathname()
const title = Object.entries(pageTitles).find(([path]) =>
path === "/dashboard" ? pathname === path : pathname.startsWith(path)
)?.[1] ?? "Property Management Network"
const action = Object.entries(pageActions).find(([path]) =>
path === "/dashboard" ? pathname === path : pathname.startsWith(path)
)?.[1]
const ActionIcon = action?.icon ?? Plus
return (
<header className="flex h-16 shrink-0 items-center justify-between border-b border-white/[0.06] bg-[#111118]/90 px-6 backdrop-blur-sm pl-16 md:pl-6">
<div>
<h1 className="text-sm font-semibold text-white leading-tight">{title}</h1>
</div>
<div className="flex items-center gap-2.5">
<CommandTrigger />
<NotificationsBell />
{action && (
<Link
href={action.href}
className="flex items-center gap-1.5 rounded-xl bg-indigo-600 px-3.5 py-2 text-xs font-semibold text-white transition-all hover:bg-indigo-500 hover:shadow-lg hover:shadow-indigo-500/25"
>
<ActionIcon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{action.label}</span>
</Link>
)}
</div>
</header>
)
}
@@ -0,0 +1,36 @@
import { cn } from "@/lib/utils"
type MaintenanceStatus = "open" | "in_progress" | "resolved" | "closed"
type Priority = "low" | "medium" | "high" | "emergency"
const statusConfig: Record<MaintenanceStatus, { label: string; className: string }> = {
open: { label: "Open", className: "text-amber-400 bg-amber-500/10 border-amber-500/20" },
in_progress: { label: "In Progress", className: "text-blue-400 bg-blue-500/10 border-blue-500/20" },
resolved: { label: "Resolved", className: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20" },
closed: { label: "Closed", className: "text-white/40 bg-white/5 border-white/10" },
}
const priorityConfig: Record<Priority, { label: string; className: string }> = {
low: { label: "Low", className: "text-white/40 bg-white/5 border-white/10" },
medium: { label: "Medium", className: "text-amber-400 bg-amber-500/10 border-amber-500/20" },
high: { label: "High", className: "text-orange-400 bg-orange-500/10 border-orange-500/20" },
emergency: { label: "Emergency", className: "text-red-400 bg-red-500/10 border-red-500/20" },
}
export function MaintenanceStatusBadge({ status }: { status: MaintenanceStatus }) {
const { label, className } = statusConfig[status] ?? statusConfig.open
return (
<span className={cn("inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium", className)}>
{label}
</span>
)
}
export function PriorityBadge({ priority }: { priority: Priority }) {
const { label, className } = priorityConfig[priority] ?? priorityConfig.medium
return (
<span className={cn("inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium", className)}>
{label}
</span>
)
}
+145
View File
@@ -0,0 +1,145 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { Bell, X, CheckCheck, AlertCircle, CreditCard, FileText, Wrench, Info } from "lucide-react"
import { formatDate } from "@/lib/utils"
interface Notification {
id: string
type: string
title: string
body: string
read: boolean
created_at: string
}
const typeIcon: Record<string, React.ElementType> = {
rent_due: CreditCard,
rent_overdue: AlertCircle,
lease_expiry: FileText,
maintenance_update: Wrench,
general: Info,
}
const typeColor: Record<string, string> = {
rent_due: "text-amber-400 bg-amber-500/10",
rent_overdue: "text-red-400 bg-red-500/10",
lease_expiry: "text-orange-400 bg-orange-500/10",
maintenance_update: "text-blue-400 bg-blue-500/10",
general: "text-white/40 bg-white/5",
}
export function NotificationsBell() {
const [open, setOpen] = useState(false)
const [notifs, setNotifs] = useState<Notification[]>([])
const [loading, setLoading] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const unread = notifs.filter(n => !n.read).length
async function load() {
setLoading(true)
try {
const res = await fetch("/api/notifications")
if (res.ok) setNotifs(await res.json())
} finally {
setLoading(false)
}
}
async function markAllRead() {
const prev = [...notifs]
setNotifs(n => n.map(x => ({ ...x, read: true })))
try {
const res = await fetch("/api/notifications/read", { method: "PATCH" })
if (!res.ok) throw new Error()
} catch {
setNotifs(prev) // rollback on failure
}
}
useEffect(() => {
load()
}, [])
useEffect(() => {
function onOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener("mousedown", onOutside)
return () => document.removeEventListener("mousedown", onOutside)
}, [])
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(v => !v)}
className="relative flex h-9 w-9 items-center justify-center rounded-xl border border-white/[0.06] bg-white/[0.03] text-white/40 hover:text-white hover:bg-white/[0.06] transition-all"
>
<Bell className="h-4 w-4" />
{unread > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[9px] font-bold text-white">
{unread > 9 ? "9+" : unread}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-11 z-50 w-80 sm:w-96 rounded-2xl border border-white/[0.08] bg-[#111118] shadow-2xl shadow-black/60 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4 text-white/40" />
<span className="text-sm font-semibold text-white">Notifications</span>
{unread > 0 && (
<span className="rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-bold text-red-400">{unread}</span>
)}
</div>
<div className="flex items-center gap-2">
{unread > 0 && (
<button onClick={markAllRead} className="flex items-center gap-1 text-[10px] text-indigo-400 hover:text-indigo-300 transition">
<CheckCheck className="h-3 w-3" /> Mark all read
</button>
)}
<button onClick={() => setOpen(false)} className="p-1 text-white/30 hover:text-white transition">
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* List */}
<div className="max-h-[400px] overflow-y-auto">
{loading ? (
<div className="py-10 text-center text-sm text-white/30">Loading</div>
) : notifs.length === 0 ? (
<div className="py-12 text-center">
<Bell className="h-8 w-8 text-white/10 mx-auto mb-3" />
<p className="text-sm text-white/30">No notifications yet</p>
</div>
) : (
<div className="divide-y divide-white/[0.04]">
{notifs.map((n) => {
const Icon = typeIcon[n.type] ?? Info
const color = typeColor[n.type] ?? typeColor.general
return (
<div key={n.id} className={`flex gap-3 px-4 py-3 transition ${!n.read ? "bg-indigo-500/[0.04]" : ""}`}>
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl ${color}`}>
<Icon className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium leading-tight ${n.read ? "text-white/60" : "text-white"}`}>{n.title}</p>
<p className="text-xs text-white/35 mt-0.5 line-clamp-2">{n.body}</p>
<p className="text-[10px] text-white/20 mt-1">{formatDate(n.created_at)}</p>
</div>
{!n.read && <div className="h-2 w-2 rounded-full bg-indigo-500 shrink-0 mt-1.5" />}
</div>
)
})}
</div>
)}
</div>
</div>
)}
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
interface OccupancyRingProps {
rate: number // 0100
occupied: number
total: number
size?: number
}
export function OccupancyRing({ rate, occupied, total, size = 80 }: OccupancyRingProps) {
const radius = (size - 12) / 2
const circumference = 2 * Math.PI * radius
const filled = (rate / 100) * circumference
const empty = circumference - filled
const color =
rate >= 80 ? "#10b981" : rate >= 50 ? "#f59e0b" : "#ef4444"
return (
<div className="flex flex-col items-center gap-1">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="-rotate-90">
{/* Track */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="rgba(255,255,255,0.06)"
strokeWidth={10}
/>
{/* Fill */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={10}
strokeDasharray={`${filled} ${empty}`}
strokeLinecap="round"
/>
</svg>
<div className="-mt-[calc(80px/2+20px)] flex flex-col items-center" style={{ marginTop: -(size / 2 + 14) }}>
<span className="text-xl font-bold text-white">{rate}%</span>
<span className="text-xs text-white/40">{occupied}/{total}</span>
</div>
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
"use client"
import { motion, AnimatePresence } from "framer-motion"
import { usePathname } from "next/navigation"
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="h-full"
>
{children}
</motion.div>
</AnimatePresence>
)
}
@@ -0,0 +1,74 @@
"use client"
import { formatCurrency } from "@/lib/utils"
import { TrendingUp } from "lucide-react"
interface MonthData { label: string; revenue: number; expense: number }
export function PropertyRevenueChart({ data }: { data: MonthData[] }) {
const maxVal = Math.max(...data.map(m => Math.max(m.revenue, m.expense)), 1)
const totalRevenue = data.reduce((s, m) => s + m.revenue, 0)
const totalExpense = data.reduce((s, m) => s + m.expense, 0)
const net = totalRevenue - totalExpense
return (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-indigo-400" />
<p className="text-sm font-semibold text-white">Revenue vs Expenses</p>
</div>
<p className={`text-sm font-bold tabular-nums ${net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
{net >= 0 ? "+" : ""}{formatCurrency(net)} net
</p>
</div>
<div className="px-5 pt-4 pb-2">
<div className="flex items-end gap-3 h-28">
{data.map((m, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-0.5">
<div className="w-full flex items-end gap-0.5" style={{ height: "96px" }}>
<div
className="flex-1 rounded-t-sm bg-indigo-500/60 hover:bg-indigo-500/90 transition"
style={{ height: `${Math.max((m.revenue / maxVal) * 100, m.revenue > 0 ? 4 : 0)}%` }}
title={`Revenue: ${formatCurrency(m.revenue)}`}
/>
<div
className="flex-1 rounded-t-sm bg-rose-500/50 hover:bg-rose-500/80 transition"
style={{ height: `${Math.max((m.expense / maxVal) * 100, m.expense > 0 ? 4 : 0)}%` }}
title={`Expenses: ${formatCurrency(m.expense)}`}
/>
</div>
<span className="text-[9px] text-white/25">{m.label}</span>
</div>
))}
</div>
</div>
<div className="grid grid-cols-3 divide-x divide-white/[0.04] border-t border-white/[0.06]">
<div className="px-4 py-2.5 text-center">
<p className="text-xs font-bold text-emerald-400 tabular-nums">{formatCurrency(totalRevenue)}</p>
<p className="text-[10px] text-white/25">Revenue</p>
</div>
<div className="px-4 py-2.5 text-center">
<p className="text-xs font-bold text-rose-400 tabular-nums">{formatCurrency(totalExpense)}</p>
<p className="text-[10px] text-white/25">Expenses</p>
</div>
<div className="px-4 py-2.5 text-center">
<p className={`text-xs font-bold tabular-nums ${net >= 0 ? "text-emerald-400" : "text-red-400"}`}>{formatCurrency(net)}</p>
<p className="text-[10px] text-white/25">Net Income</p>
</div>
</div>
{/* Legend */}
<div className="flex items-center justify-center gap-4 pb-3 pt-1">
<div className="flex items-center gap-1.5 text-[10px] text-white/30">
<span className="h-2 w-3 rounded-sm bg-indigo-500/60 inline-block" /> Revenue
</div>
<div className="flex items-center gap-1.5 text-[10px] text-white/30">
<span className="h-2 w-3 rounded-sm bg-rose-500/50 inline-block" /> Expenses
</div>
</div>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import Link from "next/link"
import { Building2, Users, CreditCard, Wrench, FileText, Receipt, ArrowRight } from "lucide-react"
const ACTIONS = [
{ label: "Add Property", icon: Building2, href: "/properties/new", color: "text-indigo-400 bg-indigo-500/10 border-indigo-500/20 hover:bg-indigo-500/20 hover:border-indigo-500/40" },
{ label: "Add Tenant", icon: Users, href: "/tenants/new", color: "text-violet-400 bg-violet-500/10 border-violet-500/20 hover:bg-violet-500/20 hover:border-violet-500/40" },
{ label: "Record Payment", icon: CreditCard, href: "/rent", color: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20 hover:bg-emerald-500/20 hover:border-emerald-500/40" },
{ label: "Log Expense", icon: Receipt, href: "/expenses", color: "text-amber-400 bg-amber-500/10 border-amber-500/20 hover:bg-amber-500/20 hover:border-amber-500/40" },
{ label: "New Lease", icon: FileText, href: "/leases/new", color: "text-blue-400 bg-blue-500/10 border-blue-500/20 hover:bg-blue-500/20 hover:border-blue-500/40" },
{ label: "New Request", icon: Wrench, href: "/maintenance/new", color: "text-rose-400 bg-rose-500/10 border-rose-500/20 hover:bg-rose-500/20 hover:border-rose-500/40" },
]
export function QuickActions() {
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-medium uppercase tracking-wider text-white/40">Quick Actions</p>
</div>
<div className="grid grid-cols-2 gap-2">
{ACTIONS.map((action) => (
<Link
key={action.label}
href={action.href}
className={`group flex items-center gap-2.5 rounded-xl border px-3 py-2.5 transition-all duration-200 ${action.color}`}
>
<action.icon className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium truncate">{action.label}</span>
<ArrowRight className="ml-auto h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</Link>
))}
</div>
</div>
)
}
@@ -0,0 +1,20 @@
import { cn } from "@/lib/utils"
type RentStatus = "pending" | "paid" | "overdue" | "partial" | "waived"
const config: Record<RentStatus, { label: string; className: string }> = {
paid: { label: "Paid", className: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20" },
pending: { label: "Pending", className: "text-amber-400 bg-amber-500/10 border-amber-500/20" },
overdue: { label: "Overdue", className: "text-red-400 bg-red-500/10 border-red-500/20" },
partial: { label: "Partial", className: "text-blue-400 bg-blue-500/10 border-blue-500/20" },
waived: { label: "Waived", className: "text-white/40 bg-white/5 border-white/10" },
}
export function RentStatusBadge({ status }: { status: RentStatus }) {
const { label, className } = config[status] ?? config.pending
return (
<span className={cn("inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium", className)}>
{label}
</span>
)
}
+97
View File
@@ -0,0 +1,97 @@
"use client"
import { formatCurrency } from "@/lib/utils"
interface MonthData {
month: string
label: string
amount: number
}
interface RevenueChartProps {
data: MonthData[]
thisMonth: number
pending: number
}
export function RevenueChart({ data, thisMonth, pending }: RevenueChartProps) {
const max = Math.max(...data.map((d) => d.amount), 1)
const total = data.reduce((sum, d) => sum + d.amount, 0)
const currentMonthKey = new Date().toISOString().slice(0, 7)
const lastEntry = data[data.length - 1]
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 h-full">
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<p className="text-xs font-medium uppercase tracking-wider text-white/40 mb-1">Revenue Overview</p>
<p className="text-2xl font-bold text-white tabular-nums">{formatCurrency(thisMonth)}</p>
<p className="text-xs text-white/40 mt-0.5">
Collected this month
{pending > 0 && <span className="text-amber-400 ml-1.5">· {formatCurrency(pending)} pending</span>}
</p>
</div>
<div className="flex items-center gap-4 text-xs text-white/40">
<div className="flex items-center gap-1.5">
<div className="h-2 w-2 rounded-full bg-indigo-500" />
Collected
</div>
</div>
</div>
{/* Bar chart */}
<div className="flex items-end justify-between gap-2 h-28">
{data.map((d) => {
const isCurrentMonth = d.month === currentMonthKey
const heightPct = max > 0 ? (d.amount / max) * 100 : 0
const minHeight = d.amount > 0 ? 8 : 4
return (
<div key={d.month} className="group flex flex-col items-center gap-1.5 flex-1">
{/* Tooltip */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity text-[10px] text-white/70 font-medium whitespace-nowrap -mt-6 absolute">
{formatCurrency(d.amount)}
</div>
{/* Bar */}
<div className="relative w-full flex items-end" style={{ height: "100px" }}>
<div
className={`w-full rounded-t-lg transition-all duration-500 ${
isCurrentMonth
? "bg-gradient-to-t from-indigo-600 to-indigo-400"
: d.amount > 0
? "bg-white/[0.12] group-hover:bg-white/[0.2]"
: "bg-white/[0.04]"
}`}
style={{ height: `${Math.max(heightPct, minHeight)}%` }}
/>
</div>
{/* Label */}
<span className={`text-[10px] font-medium ${isCurrentMonth ? "text-indigo-400" : "text-white/30"}`}>
{d.label}
</span>
</div>
)
})}
</div>
{/* Summary row */}
<div className="mt-5 pt-4 border-t border-white/[0.06] grid grid-cols-3 gap-4">
<div>
<p className="text-[10px] text-white/30 uppercase tracking-wider mb-1">6-mo total</p>
<p className="text-sm font-bold text-white tabular-nums">{formatCurrency(total)}</p>
</div>
<div>
<p className="text-[10px] text-white/30 uppercase tracking-wider mb-1">Monthly avg</p>
<p className="text-sm font-bold text-white tabular-nums">{formatCurrency(Math.round(total / 6))}</p>
</div>
<div>
<p className="text-[10px] text-white/30 uppercase tracking-wider mb-1">Best month</p>
<p className="text-sm font-bold text-white tabular-nums">{formatCurrency(max === 1 ? 0 : max)}</p>
</div>
</div>
</div>
)
}
+307
View File
@@ -0,0 +1,307 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
LayoutDashboard, Building2, Users, CreditCard,
Wrench, FileText, Receipt, Settings, LogOut,
X, Menu, ChevronRight, Zap, Sparkles, Bot, BarChart3, Hammer, ClipboardList,
PanelLeftClose, PanelLeftOpen, CalendarDays, Activity, Brain, Bell,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Logo, LogoMark } from "@/components/shared/logo"
import { signOut } from "@/app/actions/auth"
import type { Profile } from "@/types"
import { initials } from "@/lib/utils"
const navItems = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard, section: "main" },
{ label: "Properties", href: "/properties", icon: Building2, section: "main" },
{ label: "Tenants", href: "/tenants", icon: Users, section: "main" },
{ label: "Rent Tracker", href: "/rent", icon: CreditCard, section: "main" },
{ label: "Maintenance", href: "/maintenance", icon: Wrench, section: "main" },
{ label: "Leases", href: "/leases", icon: FileText, section: "main" },
{ label: "Expenses", href: "/expenses", icon: Receipt, section: "main" },
{ label: "AI Dashboard", href: "/ai-dashboard", icon: Brain, section: "main" },
{ label: "AI Assistant", href: "/ai", icon: Bot, section: "main" },
{ label: "Reports", href: "/reports", icon: BarChart3, section: "main" },
{ label: "Vendors", href: "/vendors", icon: Hammer, section: "main" },
{ label: "Inspections", href: "/inspections", icon: ClipboardList, section: "main" },
{ label: "Calendar", href: "/calendar", icon: CalendarDays, section: "main" },
{ label: "Activity", href: "/activity", icon: Activity, section: "main" },
{ label: "AI Insights", href: "/recommendations", icon: Zap, section: "main" },
{ label: "AI Impact", href: "/impact", icon: Sparkles, section: "main" },
{ label: "Predictions", href: "/predictions", icon: BarChart3, section: "main" },
{ label: "Follow-ups", href: "/follow-ups", icon: Bell, section: "main" },
]
const planConfig: Record<string, { label: string; color: string; bg: string; border: string }> = {
starter: { label: "Starter", color: "text-white/50", bg: "bg-white/5", border: "border-white/10" },
pro: { label: "Pro", color: "text-indigo-300", bg: "bg-indigo-500/10", border: "border-indigo-500/20" },
landlord: { label: "Landlord", color: "text-violet-300", bg: "bg-violet-500/10", border: "border-violet-500/20" },
lifetime: { label: "Lifetime", color: "text-amber-300", bg: "bg-amber-500/10", border: "border-amber-500/20" },
}
interface SidebarProps { profile: Profile | null }
function NavContent({
profile,
collapsed,
onClose,
}: {
profile: Profile | null
collapsed?: boolean
onClose?: () => void
}) {
const pathname = usePathname()
function isActive(href: string) {
if (href === "/dashboard") return pathname === "/dashboard"
return pathname.startsWith(href)
}
const plan = profile?.plan ?? "starter"
const pc = planConfig[plan] ?? planConfig.starter
return (
<div className="flex h-full flex-col">
{/* Logo */}
<div className={cn(
"flex h-16 shrink-0 items-center border-b border-white/[0.06]",
collapsed ? "justify-center px-2" : "justify-between px-4"
)}>
{!collapsed && <Logo />}
{collapsed && <LogoMark size="md" />}
{onClose && (
<button onClick={onClose} className="rounded-lg p-1.5 text-white/40 hover:text-white transition md:hidden">
<X className="h-5 w-5" />
</button>
)}
</div>
{/* Nav */}
<nav className={cn("flex flex-1 flex-col gap-0.5 overflow-y-auto py-3", collapsed ? "px-2" : "px-3")}>
{!collapsed && (
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-widest text-white/20">Navigation</p>
)}
{navItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
onClick={onClose}
title={collapsed ? item.label : undefined}
className={cn(
"group relative flex items-center rounded-xl transition-all duration-200",
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5",
active
? "bg-indigo-600/15 text-indigo-300"
: "text-white/50 hover:bg-white/[0.05] hover:text-white/90"
)}
>
{active && !collapsed && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full bg-indigo-500" />
)}
{active && collapsed && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full bg-indigo-500" />
)}
<Icon className={cn(
"h-4 w-4 shrink-0 transition-colors",
active ? "text-indigo-400" : "text-white/30 group-hover:text-white/60"
)} />
{!collapsed && (
<>
<span className="flex-1">{item.label}</span>
{active && <ChevronRight className="h-3 w-3 text-indigo-400/60 shrink-0" />}
</>
)}
</Link>
)
})}
<div className="my-3 border-t border-white/[0.06]" />
{!collapsed && (
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-widest text-white/20">Account</p>
)}
<Link
href="/settings/profile"
onClick={onClose}
title={collapsed ? "Settings" : undefined}
className={cn(
"group relative flex items-center rounded-xl transition-all duration-200",
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5",
pathname.startsWith("/settings") && !pathname.includes("/demo")
? "bg-indigo-600/15 text-indigo-300"
: "text-white/50 hover:bg-white/[0.05] hover:text-white/90"
)}
>
{pathname.startsWith("/settings") && !pathname.includes("/demo") && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full bg-indigo-500" />
)}
<Settings className={cn(
"h-4 w-4 shrink-0 transition-colors",
pathname.startsWith("/settings") && !pathname.includes("/demo") ? "text-indigo-400" : "text-white/30 group-hover:text-white/60"
)} />
{!collapsed && "Settings"}
</Link>
<Link
href="/settings/demo"
onClick={onClose}
title={collapsed ? "Demo Data" : undefined}
className={cn(
"group relative flex items-center rounded-xl transition-all duration-200",
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5",
pathname === "/settings/demo"
? "bg-indigo-600/15 text-indigo-300"
: "text-white/50 hover:bg-white/[0.05] hover:text-white/90"
)}
>
{pathname === "/settings/demo" && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full bg-indigo-500" />
)}
<Sparkles className={cn(
"h-4 w-4 shrink-0 transition-colors",
pathname === "/settings/demo" ? "text-indigo-400" : "text-white/30 group-hover:text-white/60"
)} />
{!collapsed && "Demo Data"}
</Link>
</nav>
{/* Bottom — plan + user */}
{!collapsed && (
<div className="shrink-0 border-t border-white/[0.06] p-3 space-y-2">
<div className={cn("flex items-center justify-between rounded-xl border px-3 py-2", pc.bg, pc.border)}>
<div className="flex items-center gap-2">
<Zap className={cn("h-3.5 w-3.5", pc.color)} />
<span className={cn("text-xs font-semibold", pc.color)}>{pc.label} Plan</span>
</div>
{plan === "starter" && (
<Link
href="/settings/billing"
onClick={onClose}
className="text-[10px] font-semibold text-indigo-400 hover:text-indigo-300 transition"
>
Upgrade
</Link>
)}
</div>
<div className="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-white/[0.03] transition group">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-xs font-bold text-white shadow-lg shadow-indigo-500/20">
{profile?.full_name ? initials(profile.full_name) : "?"}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-white">{profile?.full_name ?? "User"}</p>
<p className="truncate text-[10px] text-white/40">{profile?.email}</p>
</div>
<form action={signOut}>
<button
type="submit"
title="Sign out"
className="rounded-lg p-1.5 text-white/20 transition hover:text-red-400 hover:bg-red-500/10"
>
<LogOut className="h-3.5 w-3.5" />
</button>
</form>
</div>
</div>
)}
{/* Collapsed bottom — just avatar + logout */}
{collapsed && (
<div className="shrink-0 border-t border-white/[0.06] p-2 space-y-1">
<div
title={profile?.full_name ?? "User"}
className="flex justify-center"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-xs font-bold text-white">
{profile?.full_name ? initials(profile.full_name) : "?"}
</div>
</div>
<form action={signOut} className="flex justify-center">
<button
type="submit"
title="Sign out"
className="rounded-lg p-1.5 text-white/20 transition hover:text-red-400 hover:bg-red-500/10"
>
<LogOut className="h-3.5 w-3.5" />
</button>
</form>
</div>
)}
</div>
)
}
export function Sidebar({ profile }: SidebarProps) {
const [mobileOpen, setMobileOpen] = useState(false)
const [collapsed, setCollapsed] = useState(false)
// Persist collapse preference
useEffect(() => {
const stored = localStorage.getItem("sidebar-collapsed")
if (stored === "true") setCollapsed(true)
}, [])
function toggleCollapse() {
const next = !collapsed
setCollapsed(next)
localStorage.setItem("sidebar-collapsed", String(next))
}
return (
<>
{/* Desktop sidebar */}
<aside
className={cn(
"hidden md:flex h-screen shrink-0 flex-col border-r border-white/[0.06] bg-[#111118] transition-all duration-300",
collapsed ? "w-16" : "w-60"
)}
>
<NavContent profile={profile} collapsed={collapsed} />
{/* Collapse toggle */}
<button
onClick={toggleCollapse}
className="shrink-0 flex items-center justify-center gap-2 border-t border-white/[0.06] py-3 text-xs text-white/25 transition hover:text-white/60"
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed
? <PanelLeftOpen className="h-4 w-4" />
: <><PanelLeftClose className="h-4 w-4" /><span>Collapse</span></>
}
</button>
</aside>
{/* Mobile hamburger */}
<div className="fixed top-0 left-0 z-50 flex h-16 items-center px-4 md:hidden">
<button
onClick={() => setMobileOpen(true)}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-white/[0.06] bg-white/[0.03] text-white/60 hover:bg-white/[0.08] hover:text-white transition"
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</button>
</div>
{/* Mobile drawer */}
{mobileOpen && (
<div className="fixed inset-0 z-50 md:hidden">
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={() => setMobileOpen(false)}
/>
<aside className="absolute left-0 top-0 flex h-full w-72 flex-col bg-[#111118] shadow-2xl">
<NavContent profile={profile} onClose={() => setMobileOpen(false)} />
</aside>
</div>
)}
</>
)
}
+146
View File
@@ -0,0 +1,146 @@
import { cn } from "@/lib/utils"
import type { LucideIcon } from "lucide-react"
import { TrendingUp, TrendingDown, Minus } from "lucide-react"
import { AnimatedNumber } from "@/components/ui/animated-number"
interface StatsCardProps {
label: string
value: string | number
sub?: string
icon: LucideIcon
trend?: { value: number; label: string }
variant?: "default" | "success" | "warning" | "danger"
progress?: number
className?: string
}
export function StatsCard({
label,
value,
sub,
icon: Icon,
trend,
variant = "default",
progress,
className,
}: StatsCardProps) {
const themes = {
default: {
icon: "text-indigo-400 bg-indigo-500/10 border-indigo-500/20",
glow: "group-hover:shadow-indigo-500/10",
bar: "from-indigo-500 to-violet-500",
gradient: "group-hover:from-indigo-500/[0.04]",
},
success: {
icon: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
glow: "group-hover:shadow-emerald-500/10",
bar: "from-emerald-500 to-teal-500",
gradient: "group-hover:from-emerald-500/[0.04]",
},
warning: {
icon: "text-amber-400 bg-amber-500/10 border-amber-500/20",
glow: "group-hover:shadow-amber-500/10",
bar: "from-amber-500 to-orange-500",
gradient: "group-hover:from-amber-500/[0.04]",
},
danger: {
icon: "text-red-400 bg-red-500/10 border-red-500/20",
glow: "group-hover:shadow-red-500/10",
bar: "from-red-500 to-rose-500",
gradient: "group-hover:from-red-500/[0.04]",
},
}
const theme = themes[variant]
const trendColor =
!trend ? "" :
trend.value > 0 ? "text-emerald-400" :
trend.value < 0 ? "text-red-400" : "text-white/40"
const TrendIcon = !trend ? Minus : trend.value > 0 ? TrendingUp : trend.value < 0 ? TrendingDown : Minus
// Detect if value is a plain number (animate) or a formatted string
const isNumeric = typeof value === "number"
// For currency strings like "$4,200" — extract the number to animate
const isCurrencyString = typeof value === "string" && value.startsWith("$")
const numericVal = isNumeric
? value
: isCurrencyString
? parseFloat(value.replace(/[$,]/g, ""))
: null
return (
<div
className={cn(
"group relative overflow-hidden rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 transition-all duration-300",
"hover:border-white/[0.12] hover:shadow-xl hover:shadow-black/30",
theme.glow,
className
)}
>
{/* Hover gradient */}
<div className={cn(
"absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500",
"bg-gradient-to-br to-transparent from-transparent",
theme.gradient
)} />
{/* Top accent line */}
<div className={cn(
"absolute top-0 left-0 right-0 h-[1px] opacity-0 group-hover:opacity-100 transition-opacity duration-500",
"bg-gradient-to-r", theme.bar
)} />
<div className="relative">
{/* Label + icon */}
<div className="flex items-start justify-between">
<p className="text-xs font-medium uppercase tracking-wider text-white/40">{label}</p>
<div className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border", theme.icon)}>
<Icon className="h-4 w-4" />
</div>
</div>
{/* Value — animated if numeric */}
<p className="mt-4 text-3xl font-bold tracking-tight text-white tabular-nums">
{numericVal !== null ? (
<AnimatedNumber
value={numericVal}
format={isCurrencyString ? "currency" : "number"}
/>
) : (
value
)}
</p>
{/* Trend */}
{trend && (
<div className="mt-1.5 flex items-center gap-1.5">
<TrendIcon className={cn("h-3.5 w-3.5", trendColor)} />
<span className={cn("text-xs font-semibold tabular-nums", trendColor)}>
{trend.value > 0 ? "+" : ""}{trend.value}%
</span>
<span className="text-xs text-white/30">{trend.label}</span>
</div>
)}
{/* Progress bar */}
{progress !== undefined && (
<div className="mt-4">
<div className="h-1.5 overflow-hidden rounded-full bg-white/[0.06]">
<div
className={cn("h-full rounded-full bg-gradient-to-r transition-all duration-700", theme.bar)}
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
/>
</div>
</div>
)}
{/* Sub */}
{sub && (
<p className="mt-2.5 text-xs text-white/40 leading-snug">{sub}</p>
)}
</div>
</div>
)
}