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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 “{query}”</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
interface OccupancyRingProps {
|
||||
rate: number // 0–100
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user