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,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