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,159 @@
|
||||
"use client"
|
||||
|
||||
// Dependency-free, dark-themed admin charts (Tailwind divs + inline SVG).
|
||||
// Mirrors the look of components/dashboard/revenue-chart.tsx — no recharts.
|
||||
|
||||
const PLAN_META: { key: string; label: string; color: string; text: string }[] = [
|
||||
{ key: "starter", label: "Starter", color: "rgba(255,255,255,0.4)", text: "text-white/40" },
|
||||
{ key: "pro", label: "Pro", color: "#6366f1", text: "text-indigo-400" }, // indigo
|
||||
{ key: "landlord", label: "Landlord", color: "#8b5cf6", text: "text-violet-400" }, // violet
|
||||
{ key: "lifetime", label: "Lifetime", color: "#f59e0b", text: "text-amber-400" }, // amber
|
||||
]
|
||||
|
||||
export function PlanDonut({ data }: { data: Record<string, number> }) {
|
||||
const total = PLAN_META.reduce((sum, p) => sum + (data[p.key] ?? 0), 0)
|
||||
|
||||
// Donut geometry
|
||||
const size = 160
|
||||
const stroke = 22
|
||||
const radius = (size - stroke) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
|
||||
// Build cumulative arc segments
|
||||
let cumulative = 0
|
||||
const segments = PLAN_META.map((p) => {
|
||||
const value = data[p.key] ?? 0
|
||||
const fraction = total > 0 ? value / total : 0
|
||||
const dash = fraction * circumference
|
||||
const offset = cumulative * circumference
|
||||
cumulative += fraction
|
||||
return { ...p, value, fraction, dash, offset }
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 h-full">
|
||||
<div className="mb-5">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-white/40 mb-1">Plan Distribution</p>
|
||||
<p className="text-2xl font-bold text-white tabular-nums">{total.toLocaleString()}</p>
|
||||
<p className="text-xs text-white/40 mt-0.5">Total accounts</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-6">
|
||||
{/* Donut */}
|
||||
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||||
<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.05)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
{total > 0 &&
|
||||
segments.map(
|
||||
(s) =>
|
||||
s.value > 0 && (
|
||||
<circle
|
||||
key={s.key}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={`${s.dash} ${circumference - s.dash}`}
|
||||
strokeDashoffset={-s.offset}
|
||||
strokeLinecap="butt"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-2xl font-bold text-white tabular-nums leading-none">{total.toLocaleString()}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-white/30 mt-1">accounts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex-1 w-full space-y-2.5">
|
||||
{segments.map((s) => {
|
||||
const pct = total > 0 ? Math.round(s.fraction * 100) : 0
|
||||
return (
|
||||
<div key={s.key} className="flex items-center gap-3">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ backgroundColor: s.color }} />
|
||||
<span className="flex-1 text-sm text-white/70">{s.label}</span>
|
||||
<span className="text-sm font-semibold text-white tabular-nums">{s.value.toLocaleString()}</span>
|
||||
<span className="w-10 text-right text-xs text-white/40 tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SignupsBars({ data }: { data: { month: string; label: string; count: number }[] }) {
|
||||
const max = Math.max(...data.map((d) => d.count), 1)
|
||||
const total = data.reduce((sum, d) => sum + d.count, 0)
|
||||
const currentMonthKey = new Date().toISOString().slice(0, 7)
|
||||
const last = data[data.length - 1]?.count ?? 0
|
||||
const prev = data[data.length - 2]?.count ?? 0
|
||||
const trendPct = prev > 0 ? Math.round(((last - prev) / prev) * 100) : null
|
||||
|
||||
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">New Signups</p>
|
||||
<p className="text-2xl font-bold text-white tabular-nums">{total.toLocaleString()}</p>
|
||||
<p className="text-xs text-white/40 mt-0.5">
|
||||
Last {data.length} months
|
||||
{trendPct !== null && (
|
||||
<span className={trendPct >= 0 ? "text-emerald-400 ml-1.5" : "text-red-400 ml-1.5"}>
|
||||
· {trendPct >= 0 ? "+" : ""}
|
||||
{trendPct}% vs prev
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-white/40">
|
||||
<div className="h-2 w-2 rounded-full bg-rose-500" />
|
||||
Signups
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bars */}
|
||||
<div className="flex items-end justify-between gap-2 h-28">
|
||||
{data.map((d) => {
|
||||
const isCurrent = d.month === currentMonthKey
|
||||
const heightPct = max > 0 ? (d.count / max) * 100 : 0
|
||||
const minHeight = d.count > 0 ? 8 : 4
|
||||
return (
|
||||
<div key={d.month} className="group flex flex-col items-center gap-1.5 flex-1">
|
||||
<span className="text-[10px] font-medium text-white/50 tabular-nums">{d.count}</span>
|
||||
<div className="relative w-full flex items-end" style={{ height: "100px" }}>
|
||||
<div
|
||||
className={`w-full rounded-t-lg transition-all duration-500 ${
|
||||
isCurrent
|
||||
? "bg-gradient-to-t from-rose-600 to-rose-400"
|
||||
: d.count > 0
|
||||
? "bg-white/[0.12] group-hover:bg-white/[0.2]"
|
||||
: "bg-white/[0.04]"
|
||||
}`}
|
||||
style={{ height: `${Math.max(heightPct, minHeight)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-[10px] font-medium ${isCurrent ? "text-rose-400" : "text-white/30"}`}>
|
||||
{d.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ADMIN_NAV } from "@/components/admin/admin-sidebar"
|
||||
|
||||
export function AdminHeader() {
|
||||
const pathname = usePathname()
|
||||
const current = ADMIN_NAV.find((n) =>
|
||||
n.href === "/admin" ? pathname === "/admin" : pathname.startsWith(n.href)
|
||||
)
|
||||
|
||||
return (
|
||||
<header className="shrink-0 border-b border-white/[0.06] bg-[#111118]/90 backdrop-blur-sm">
|
||||
<div className="flex h-16 items-center justify-between px-6">
|
||||
<h1 className="text-sm font-semibold text-white">{current?.label ?? "Admin"}</h1>
|
||||
<span className="rounded-full border border-rose-500/30 bg-rose-500/10 px-2.5 py-1 text-[10px] font-semibold tracking-wide text-rose-300">
|
||||
SUPERADMIN
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Mobile nav (sidebar is hidden under md) */}
|
||||
<nav className="flex items-center gap-1 overflow-x-auto border-t border-white/[0.06] px-3 py-2 md:hidden">
|
||||
{ADMIN_NAV.map(({ label, href }) => {
|
||||
const active = href === "/admin" ? pathname === "/admin" : pathname.startsWith(href)
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium transition",
|
||||
active ? "bg-rose-600/15 text-rose-200" : "text-white/50 hover:text-white/90"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
LayoutDashboard, Users, CreditCard, Activity, Brain, Server,
|
||||
ArrowLeft, LogOut, ShieldCheck,
|
||||
} from "lucide-react"
|
||||
import { cn, initials } from "@/lib/utils"
|
||||
import { signOut } from "@/app/actions/auth"
|
||||
|
||||
export const ADMIN_NAV = [
|
||||
{ label: "Overview", href: "/admin", icon: LayoutDashboard },
|
||||
{ label: "Users", href: "/admin/users", icon: Users },
|
||||
{ label: "Billing", href: "/admin/billing", icon: CreditCard },
|
||||
{ label: "Activity & Audit", href: "/admin/activity", icon: Activity },
|
||||
{ label: "AI Usage", href: "/admin/ai-usage", icon: Brain },
|
||||
{ label: "System", href: "/admin/system", icon: Server },
|
||||
]
|
||||
|
||||
function isActive(pathname: string, href: string) {
|
||||
return href === "/admin" ? pathname === "/admin" : pathname.startsWith(href)
|
||||
}
|
||||
|
||||
export function AdminSidebar({ email, name }: { email: string; name: string }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<aside className="hidden md:flex h-screen w-60 shrink-0 flex-col border-r border-white/[0.06] bg-[#111118]">
|
||||
<div className="flex h-16 items-center gap-2.5 border-b border-white/[0.06] px-5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-rose-500 to-red-600 shadow-lg shadow-rose-500/20">
|
||||
<ShieldCheck className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold leading-none text-white">Admin</p>
|
||||
<p className="mt-0.5 text-[10px] text-white/40">Control Panel</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-3">
|
||||
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-widest text-white/20">Platform</p>
|
||||
{ADMIN_NAV.map(({ label, href, icon: Icon }) => {
|
||||
const active = isActive(pathname, href)
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-all",
|
||||
active ? "bg-rose-600/15 text-rose-200" : "text-white/50 hover:bg-white/[0.05] hover:text-white/90"
|
||||
)}
|
||||
>
|
||||
{active && <div className="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-rose-500" />}
|
||||
<Icon className={cn("h-4 w-4 shrink-0", active ? "text-rose-400" : "text-white/30 group-hover:text-white/60")} />
|
||||
<span className="flex-1">{label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="my-3 border-t border-white/[0.06]" />
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm text-white/50 transition hover:bg-white/[0.05] hover:text-white/90"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 shrink-0 text-white/30" />
|
||||
Back to App
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="shrink-0 border-t border-white/[0.06] p-3">
|
||||
<div className="flex items-center gap-3 rounded-xl px-2 py-2">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-rose-500 to-red-600 text-xs font-bold text-white">
|
||||
{name ? initials(name) : "A"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-semibold text-white">{name || "Admin"}</p>
|
||||
<p className="truncate text-[10px] text-white/40">{email}</p>
|
||||
</div>
|
||||
<form action={signOut}>
|
||||
<button
|
||||
type="submit"
|
||||
title="Sign out"
|
||||
className="rounded-lg p-1.5 text-white/20 transition hover:bg-red-500/10 hover:text-red-400"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ShieldAlert } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
/**
|
||||
* Shown in the app shell whenever the current session was created via admin
|
||||
* impersonation. Lets the admin end the impersonation and return to /admin.
|
||||
*/
|
||||
export function ImpersonationBanner({ label }: { label?: string }) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function stop() {
|
||||
setLoading(true)
|
||||
try {
|
||||
await authClient.admin.stopImpersonating()
|
||||
router.push("/admin/users")
|
||||
router.refresh()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-[60] flex items-center justify-center gap-3 bg-amber-500 px-4 py-2 text-center text-sm font-semibold text-black">
|
||||
<ShieldAlert className="h-4 w-4 shrink-0" />
|
||||
<span>Viewing as {label ?? "another user"} — actions affect their account.</span>
|
||||
<button
|
||||
onClick={stop}
|
||||
disabled={loading}
|
||||
className="rounded-md bg-black/85 px-3 py-1 text-xs font-bold text-white transition hover:bg-black disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Returning…" : "Return to admin"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Ban, ShieldCheck, UserCog, MailCheck, Trash2, Crown } from "lucide-react"
|
||||
import {
|
||||
changeUserPlan,
|
||||
banUser,
|
||||
unbanUser,
|
||||
impersonateUser,
|
||||
markEmailVerified,
|
||||
deleteUser,
|
||||
} from "@/app/actions/admin"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { ConfirmModal } from "@/components/ui/confirm-modal"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface UserActionsProps {
|
||||
userId: string
|
||||
email: string
|
||||
currentPlan: string
|
||||
banned: boolean
|
||||
isSelf: boolean
|
||||
}
|
||||
|
||||
const PLAN_OPTIONS = [
|
||||
{ value: "starter", label: "Starter" },
|
||||
{ value: "pro", label: "Pro" },
|
||||
{ value: "landlord", label: "Landlord" },
|
||||
{ value: "lifetime", label: "Lifetime" },
|
||||
]
|
||||
|
||||
function errMessage(e: unknown) {
|
||||
return e instanceof Error ? e.message : "Something went wrong"
|
||||
}
|
||||
|
||||
export function UserActions({ userId, email, currentPlan, banned, isSelf }: UserActionsProps) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [plan, setPlan] = useState(currentPlan)
|
||||
const [showBan, setShowBan] = useState(false)
|
||||
const [banReason, setBanReason] = useState("")
|
||||
const [showImpersonate, setShowImpersonate] = useState(false)
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
|
||||
// Run a server action inside a transition; toast on success/error, then refresh.
|
||||
function run(fn: () => Promise<unknown>, successMsg: string, after?: () => void) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await fn()
|
||||
toast.success(successMsg)
|
||||
after?.()
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
toast.error(errMessage(e))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onApplyPlan() {
|
||||
if (plan === currentPlan) {
|
||||
toast.message("Plan unchanged")
|
||||
return
|
||||
}
|
||||
run(() => changeUserPlan(userId, plan), "Plan updated")
|
||||
}
|
||||
|
||||
function onBan() {
|
||||
run(() => banUser(userId, banReason.trim() || undefined), "User banned", () => {
|
||||
setShowBan(false)
|
||||
setBanReason("")
|
||||
})
|
||||
}
|
||||
|
||||
function onUnban() {
|
||||
run(() => unbanUser(userId), "User unbanned")
|
||||
}
|
||||
|
||||
function onImpersonate() {
|
||||
// impersonateUser redirects to /dashboard on success — no toast needed.
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await impersonateUser(userId)
|
||||
} catch (e) {
|
||||
toast.error(errMessage(e))
|
||||
setShowImpersonate(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onVerify() {
|
||||
run(() => markEmailVerified(userId), "Email marked as verified")
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
// deleteUser redirects to /admin/users on success.
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteUser(userId)
|
||||
} catch (e) {
|
||||
toast.error(errMessage(e))
|
||||
setShowDelete(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const btnBase =
|
||||
"w-full inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Plan */}
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Crown className="h-4 w-4 text-amber-400" />
|
||||
<h3 className="text-sm font-semibold">Change plan</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-white/40">Override the user's subscription tier.</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<Select value={plan} onChange={setPlan} options={PLAN_OPTIONS} />
|
||||
<button
|
||||
onClick={onApplyPlan}
|
||||
disabled={isPending}
|
||||
className={cn(btnBase, "bg-indigo-600 text-white hover:bg-indigo-500")}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account actions */}
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<UserCog className="h-4 w-4 text-rose-400" />
|
||||
<h3 className="text-sm font-semibold">Account</h3>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2.5">
|
||||
{/* Ban / Unban */}
|
||||
{banned ? (
|
||||
<button
|
||||
onClick={onUnban}
|
||||
disabled={isPending}
|
||||
className={cn(btnBase, "border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20")}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" /> Unban user
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowBan(true)}
|
||||
disabled={isPending || isSelf}
|
||||
title={isSelf ? "You cannot ban yourself" : undefined}
|
||||
className={cn(btnBase, "border border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20")}
|
||||
>
|
||||
<Ban className="h-4 w-4" /> Ban user
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Impersonate */}
|
||||
<button
|
||||
onClick={() => setShowImpersonate(true)}
|
||||
disabled={isPending || isSelf}
|
||||
title={isSelf ? "You cannot impersonate yourself" : undefined}
|
||||
className={cn(btnBase, "border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:text-white")}
|
||||
>
|
||||
<UserCog className="h-4 w-4" /> Impersonate
|
||||
</button>
|
||||
|
||||
{/* Verify email */}
|
||||
<button
|
||||
onClick={onVerify}
|
||||
disabled={isPending}
|
||||
className={cn(btnBase, "border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:text-white")}
|
||||
>
|
||||
<MailCheck className="h-4 w-4" /> Mark email verified
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="rounded-2xl border border-red-500/20 bg-red-500/[0.03] p-5">
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">Danger zone</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
Permanently deletes the user and ALL their data. This cannot be undone.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowDelete(true)}
|
||||
disabled={isPending || isSelf}
|
||||
title={isSelf ? "You cannot delete yourself" : undefined}
|
||||
className={cn(btnBase, "mt-4 bg-red-600 text-white hover:bg-red-500")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> Delete user
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ban modal (with reason input) */}
|
||||
{showBan && (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center px-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => !isPending && setShowBan(false)} />
|
||||
<div className="relative w-full max-w-sm overflow-hidden rounded-2xl border border-white/[0.08] bg-[#16161f] p-6 shadow-2xl shadow-black/80">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border border-amber-500/20 bg-amber-500/10 text-amber-400">
|
||||
<Ban className="h-6 w-6" />
|
||||
</div>
|
||||
<h2 className="text-center text-base font-bold text-white">Ban {email}?</h2>
|
||||
<p className="mt-2 text-center text-sm text-white/50">
|
||||
The user will be signed out and blocked from signing in until unbanned.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<label className="mb-1.5 block text-xs font-medium text-white/40">Reason (optional)</label>
|
||||
<input
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
placeholder="Banned by admin"
|
||||
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-2.5 text-sm text-white placeholder-white/25 outline-none transition focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowBan(false)}
|
||||
disabled={isPending}
|
||||
className="flex-1 rounded-xl border border-white/10 py-2.5 text-sm font-medium text-white/50 transition hover:border-white/20 hover:text-white disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onBan}
|
||||
disabled={isPending}
|
||||
className="flex-1 rounded-xl bg-amber-600 py-2.5 text-sm font-semibold text-white transition hover:bg-amber-500 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Banning…" : "Ban user"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Impersonate confirm */}
|
||||
<ConfirmModal
|
||||
open={showImpersonate}
|
||||
variant="warning"
|
||||
title={`Impersonate ${email}?`}
|
||||
description="You will be signed in as this user and redirected to their dashboard. Your admin session can be restored from the impersonation banner."
|
||||
confirmLabel="Impersonate"
|
||||
loading={isPending}
|
||||
onConfirm={onImpersonate}
|
||||
onCancel={() => setShowImpersonate(false)}
|
||||
/>
|
||||
|
||||
{/* Delete confirm */}
|
||||
<ConfirmModal
|
||||
open={showDelete}
|
||||
variant="danger"
|
||||
title={`Delete ${email}?`}
|
||||
description="This permanently deletes the user and ALL their data — properties, units, tenants, leases, payments and more. This action cannot be undone."
|
||||
confirmLabel="Delete user"
|
||||
loading={isPending}
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setShowDelete(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
Search,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
Users as UsersIcon,
|
||||
ShieldAlert,
|
||||
Ban,
|
||||
} from "lucide-react"
|
||||
import type { AdminUserRow } from "@/lib/db/admin-queries"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { EmptyState } from "@/components/shared/empty-state"
|
||||
import { formatDate, initials, cn } from "@/lib/utils"
|
||||
|
||||
interface UsersTableProps {
|
||||
data: {
|
||||
rows: AdminUserRow[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
pageCount: number
|
||||
}
|
||||
query: { q?: string; plan?: string; sort?: string; dir?: string }
|
||||
}
|
||||
|
||||
const PLAN_BADGE: Record<string, string> = {
|
||||
starter: "border-white/15 bg-white/[0.04] text-white/40",
|
||||
pro: "border-indigo-500/30 bg-indigo-500/10 text-indigo-300",
|
||||
landlord: "border-violet-500/30 bg-violet-500/10 text-violet-300",
|
||||
lifetime: "border-amber-500/30 bg-amber-500/10 text-amber-300",
|
||||
}
|
||||
|
||||
function PlanBadge({ plan }: { plan: string | null }) {
|
||||
const key = plan ?? "starter"
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium capitalize",
|
||||
PLAN_BADGE[key] ?? PLAN_BADGE.starter
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const PLAN_OPTIONS = [
|
||||
{ value: "", label: "All plans" },
|
||||
{ value: "starter", label: "Starter" },
|
||||
{ value: "pro", label: "Pro" },
|
||||
{ value: "landlord", label: "Landlord" },
|
||||
{ value: "lifetime", label: "Lifetime" },
|
||||
]
|
||||
|
||||
export function UsersTable({ data, query }: UsersTableProps) {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState(query.q ?? "")
|
||||
const { rows, total, page, pageCount } = data
|
||||
|
||||
// Build a /admin/users URL preserving existing params, applying overrides,
|
||||
// and resetting to page 1 whenever a filter/search changes.
|
||||
function buildUrl(overrides: Record<string, string | undefined>) {
|
||||
const params = new URLSearchParams()
|
||||
const merged: Record<string, string | undefined> = {
|
||||
q: query.q,
|
||||
plan: query.plan,
|
||||
sort: query.sort,
|
||||
dir: query.dir,
|
||||
...overrides,
|
||||
}
|
||||
for (const [k, v] of Object.entries(merged)) {
|
||||
if (v != null && v !== "") params.set(k, v)
|
||||
}
|
||||
const qs = params.toString()
|
||||
return qs ? `/admin/users?${qs}` : "/admin/users"
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
router.push(buildUrl({ q: search.trim() || undefined, page: undefined }))
|
||||
}
|
||||
|
||||
function onPlanChange(plan: string) {
|
||||
router.push(buildUrl({ plan: plan || undefined, page: undefined }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-white/25" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submitSearch()
|
||||
}}
|
||||
placeholder="Search by name or email…"
|
||||
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.03] py-2.5 pl-10 pr-4 text-sm text-white placeholder-white/25 outline-none transition focus:border-rose-500/50 focus:bg-white/[0.05] focus:ring-1 focus:ring-rose-500/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={query.plan ?? ""}
|
||||
onChange={onPlanChange}
|
||||
options={PLAN_OPTIONS}
|
||||
placeholder="All plans"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={submitSearch}
|
||||
className="rounded-xl bg-rose-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-rose-500 hover:shadow-lg hover:shadow-rose-500/20"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={UsersIcon}
|
||||
title="No users found"
|
||||
description="No accounts match the current search or filter. Try adjusting your query."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden overflow-hidden rounded-2xl border border-white/[0.06] bg-[#16161f] lg:block">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
<th className="px-5 py-3.5 text-left text-xs font-medium tracking-wide text-white/30">Email</th>
|
||||
<th className="px-5 py-3.5 text-left text-xs font-medium tracking-wide text-white/30">Name</th>
|
||||
<th className="px-5 py-3.5 text-left text-xs font-medium tracking-wide text-white/30">Plan</th>
|
||||
<th className="px-5 py-3.5 text-left text-xs font-medium tracking-wide text-white/30">Status</th>
|
||||
<th className="px-5 py-3.5 text-right text-xs font-medium tracking-wide text-white/30">Properties</th>
|
||||
<th className="px-5 py-3.5 text-right text-xs font-medium tracking-wide text-white/30">Tenants</th>
|
||||
<th className="px-5 py-3.5 text-left text-xs font-medium tracking-wide text-white/30">Joined</th>
|
||||
<th className="px-5 py-3.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="group transition-colors hover:bg-white/[0.02]">
|
||||
<td className="px-5 py-4">
|
||||
<Link href={`/admin/users/${row.id}`} className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-rose-500/20 to-red-500/20 text-xs font-bold text-rose-300 ring-1 ring-inset ring-rose-500/20">
|
||||
{initials(row.full_name || row.email)}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-white">{row.email}</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="text-sm text-white/70">{row.full_name || "—"}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<PlanBadge plan={row.plan} />
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
{row.banned ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-xs font-medium text-red-400">
|
||||
<Ban className="h-3 w-3" /> Banned
|
||||
</span>
|
||||
) : row.role === "admin" ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-rose-500/30 bg-rose-500/10 px-2 py-0.5 text-xs font-medium text-rose-300">
|
||||
<ShieldAlert className="h-3 w-3" /> Admin
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs capitalize text-white/40">
|
||||
{row.subscription_status || "active"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right text-sm tabular-nums text-white/70">{row.propertyCount}</td>
|
||||
<td className="px-5 py-4 text-right text-sm tabular-nums text-white/70">{row.tenantCount}</td>
|
||||
<td className="px-5 py-4 text-sm text-white/50">{formatDate(row.created_at)}</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<Link
|
||||
href={`/admin/users/${row.id}`}
|
||||
className="inline-flex items-center gap-1 text-xs text-white/30 transition group-hover:text-rose-400"
|
||||
>
|
||||
View <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile / tablet cards */}
|
||||
<div className="space-y-2 lg:hidden">
|
||||
{rows.map((row) => (
|
||||
<Link
|
||||
key={row.id}
|
||||
href={`/admin/users/${row.id}`}
|
||||
className="flex items-center gap-3 rounded-2xl border border-white/[0.06] bg-[#16161f] p-4 transition hover:border-rose-500/20 hover:bg-[#1a1a2e]"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-rose-500/20 to-red-500/20 text-sm font-bold text-rose-300 ring-1 ring-inset ring-rose-500/20">
|
||||
{initials(row.full_name || row.email)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold text-white">{row.full_name || row.email}</p>
|
||||
{row.banned && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-red-500/30 bg-red-500/10 px-1.5 py-0.5 text-[10px] font-medium text-red-400">
|
||||
<Ban className="h-2.5 w-2.5" /> Banned
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-white/35">{row.email}</p>
|
||||
<div className="mt-1.5 flex items-center gap-3 text-xs text-white/40">
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{row.propertyCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<UsersIcon className="h-3 w-3" />
|
||||
{row.tenantCount}
|
||||
</span>
|
||||
<span>{formatDate(row.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<PlanBadge plan={row.plan} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex flex-col items-center justify-between gap-3 pt-1 sm:flex-row">
|
||||
<p className="text-xs text-white/40">
|
||||
Page {page} of {pageCount} · {total.toLocaleString()} {total === 1 ? "user" : "users"}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{page > 1 ? (
|
||||
<Link
|
||||
href={buildUrl({ page: String(page - 1) })}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60 transition hover:border-white/20 hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" /> Prev
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex cursor-not-allowed items-center gap-1 rounded-lg border border-white/[0.04] px-3 py-1.5 text-xs font-medium text-white/20">
|
||||
<ArrowLeft className="h-3 w-3" /> Prev
|
||||
</span>
|
||||
)}
|
||||
{page < pageCount ? (
|
||||
<Link
|
||||
href={buildUrl({ page: String(page + 1) })}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60 transition hover:border-white/20 hover:text-white"
|
||||
>
|
||||
Next <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex cursor-not-allowed items-center gap-1 rounded-lg border border-white/[0.04] px-3 py-1.5 text-xs font-medium text-white/20">
|
||||
Next <ArrowRight className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Sparkles, Loader2, X, AlertTriangle, TrendingUp, Wrench } from "lucide-react"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
|
||||
interface Summary {
|
||||
reportTitle: string
|
||||
reportDate: string
|
||||
propertyName: string
|
||||
totalRequests: number
|
||||
openRequests: number
|
||||
resolvedRequests: number
|
||||
urgentItems: string[]
|
||||
summary: string
|
||||
recommendations: string[]
|
||||
estimatedTotalCost: number
|
||||
}
|
||||
|
||||
export function AiMaintenanceSummary({ propertyId }: { propertyId: string }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [summary, setSummary] = useState<Summary | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
async function generate() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai/maintenance-summary", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ property_id: propertyId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setError(data.error ?? "Failed to generate summary"); return }
|
||||
setSummary(data)
|
||||
} catch {
|
||||
setError("Network error. Please try again.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-indigo-500/30 bg-indigo-500/10 px-3 py-2 text-xs font-medium text-indigo-300 hover:bg-indigo-500/20 transition-all disabled:opacity-60"
|
||||
>
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
AI Summary
|
||||
</button>
|
||||
|
||||
{/* Slide-over panel */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setOpen(false)} />
|
||||
<div className="relative w-full max-w-md bg-[#111118] border-l border-white/[0.06] overflow-y-auto shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-indigo-400" />
|
||||
<h2 className="text-sm font-semibold text-white">AI Maintenance Report</h2>
|
||||
</div>
|
||||
<button onClick={() => setOpen(false)} className="rounded-lg p-1.5 text-white/40 hover:text-white transition">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||
<Loader2 className="h-8 w-8 text-indigo-400 animate-spin" />
|
||||
<p className="text-sm text-white/50">Analysing maintenance data…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && !loading && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">{summary.reportTitle}</h3>
|
||||
<p className="text-xs text-white/40 mt-0.5">{summary.reportDate} · {summary.propertyName}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Total", value: summary.totalRequests, color: "text-white" },
|
||||
{ label: "Open", value: summary.openRequests, color: "text-amber-400" },
|
||||
{ label: "Resolved", value: summary.resolvedRequests, color: "text-emerald-400" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3 text-center">
|
||||
<p className={`text-xl font-bold ${s.color}`}>{s.value}</p>
|
||||
<p className="text-xs text-white/40 mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cost */}
|
||||
{summary.estimatedTotalCost > 0 && (
|
||||
<div className="flex items-center justify-between rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3">
|
||||
<span className="text-xs text-amber-400/80">Estimated total cost</span>
|
||||
<span className="text-sm font-bold text-amber-400">{formatCurrency(summary.estimatedTotalCost)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-white/30 mb-2 flex items-center gap-1.5">
|
||||
<TrendingUp className="h-3 w-3" /> Overview
|
||||
</p>
|
||||
<p className="text-sm text-white/70 leading-relaxed">{summary.summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Urgent items */}
|
||||
{summary.urgentItems?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-red-400/70 mb-2 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-3 w-3" /> Urgent Items
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{summary.urgentItems.map((item, i) => (
|
||||
<li key={i} className="text-sm text-white/70 flex items-start gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-400" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{summary.recommendations?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-indigo-400/70 mb-2 flex items-center gap-1.5">
|
||||
<Wrench className="h-3 w-3" /> Recommendations
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{summary.recommendations.map((rec, i) => (
|
||||
<li key={i} className="text-sm text-white/70 flex items-start gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-indigo-500/20 text-[10px] font-bold text-indigo-400">{i + 1}</span>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={generate}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl border border-white/[0.06] px-4 py-2.5 text-xs font-medium text-white/50 hover:text-white hover:border-white/20 transition"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Regenerate report
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function CheckoutButton({ plan, label, highlight }: { plan: string; label: string; highlight?: boolean }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true)
|
||||
const res = await fetch("/api/stripe/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ plan }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) window.location.href = data.url
|
||||
else setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50",
|
||||
highlight
|
||||
? "bg-indigo-600 text-white hover:bg-indigo-500"
|
||||
: "border border-white/10 text-white/70 hover:border-white/20 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{loading ? "Loading..." : label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Download } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface Props {
|
||||
endpoint: string // e.g. "/api/export/rent" or "/api/export/tenants"
|
||||
filename: string // e.g. "rent-payments.csv"
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function CsvExportButton({ endpoint, filename, label = "Export CSV" }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleExport() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(endpoint)
|
||||
if (!res.ok) throw new Error("Export failed")
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(`${filename} downloaded`)
|
||||
} catch {
|
||||
toast.error("Failed to export CSV")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition disabled:opacity-40"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{loading ? "Exporting…" : label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Trash2 } from "lucide-react"
|
||||
|
||||
export function DeletePropertyButton({ propertyId }: { propertyId: string }) {
|
||||
const router = useRouter()
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleDelete() {
|
||||
setLoading(true)
|
||||
await fetch(`/api/properties/${propertyId}`, { method: "DELETE" })
|
||||
router.push("/properties")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
if (confirming) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-white/50">Are you sure?</span>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-medium text-white hover:bg-red-500 disabled:opacity-50 transition"
|
||||
>
|
||||
{loading ? "Deleting..." : "Yes, delete"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
className="rounded-lg border border-white/10 px-3 py-2 text-xs text-white/60 hover:text-white transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setConfirming(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-500/20 px-3 py-2 text-sm text-red-400 hover:border-red-500/40 hover:bg-red-500/5 transition"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Delete
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useWarnUnsaved } from "@/lib/hooks/use-warn-unsaved"
|
||||
import { Select } from "@/components/ui/select"
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "repairs", label: "Repairs" },
|
||||
{ value: "utilities", label: "Utilities" },
|
||||
{ value: "insurance", label: "Insurance" },
|
||||
{ value: "mortgage", label: "Mortgage" },
|
||||
{ value: "taxes", label: "Taxes" },
|
||||
{ value: "management", label: "Management" },
|
||||
{ value: "supplies", label: "Supplies" },
|
||||
{ value: "other", label: "Other" },
|
||||
]
|
||||
|
||||
const RECURRENCE = [
|
||||
{ value: "monthly", label: "Monthly" },
|
||||
{ value: "quarterly", label: "Quarterly" },
|
||||
{ value: "yearly", label: "Yearly" },
|
||||
]
|
||||
|
||||
export function ExpenseForm({ properties, expense }: { properties: any[]; expense?: any }) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [selectedPropertyId, setSelectedPropertyId] = useState(expense?.property_id ?? "")
|
||||
const [isRecurring, setIsRecurring] = useState(expense?.is_recurring ?? false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
useWarnUnsaved(isDirty)
|
||||
|
||||
const propertyOptions = properties.map((p: any) => ({ value: p.id, label: p.name }))
|
||||
const units = properties.find((p: any) => p.id === selectedPropertyId)?.units ?? []
|
||||
const unitOptions = [
|
||||
{ value: "", label: "Whole property" },
|
||||
...units.map((u: any) => ({ value: u.id, label: `Unit ${u.unit_number}` })),
|
||||
]
|
||||
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const lbl = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
property_id: fd.get("property_id"),
|
||||
unit_id: fd.get("unit_id") || undefined,
|
||||
category: fd.get("category"),
|
||||
description: fd.get("description"),
|
||||
amount: Number(fd.get("amount")),
|
||||
expense_date: fd.get("expense_date"),
|
||||
vendor: fd.get("vendor") || undefined,
|
||||
is_recurring: fd.get("is_recurring") === "on",
|
||||
recurrence: fd.get("recurrence") || undefined,
|
||||
notes: fd.get("notes") || undefined,
|
||||
}
|
||||
|
||||
const res = await fetch(expense ? `/api/expenses/${expense.id}` : "/api/expenses", {
|
||||
method: expense ? "PATCH" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(typeof data.error === "string" ? data.error : "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
setIsDirty(false)
|
||||
toast.success(expense ? "Expense updated" : "Expense added")
|
||||
router.push("/expenses")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} onChange={() => setIsDirty(true)} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && <div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Description <span className="text-red-400">*</span></label>
|
||||
<input name="description" required placeholder="e.g. Plumbing repair" defaultValue={expense?.description} className={cls} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Amount ($) <span className="text-red-400">*</span></label>
|
||||
<input name="amount" type="number" step="0.01" min="0" required placeholder="0.00" defaultValue={expense?.amount} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Date <span className="text-red-400">*</span></label>
|
||||
<input name="expense_date" type="date" required defaultValue={expense?.expense_date ?? today} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Category <span className="text-red-400">*</span></label>
|
||||
<Select name="category" defaultValue={expense?.category ?? "repairs"} options={CATEGORIES} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Vendor</label>
|
||||
<input name="vendor" placeholder="Vendor / contractor" defaultValue={expense?.vendor ?? ""} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Property <span className="text-red-400">*</span></label>
|
||||
<Select
|
||||
name="property_id"
|
||||
value={selectedPropertyId}
|
||||
onChange={setSelectedPropertyId}
|
||||
options={[{ value: "", label: "Select property…" }, ...propertyOptions]}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedPropertyId && units.length > 0 && (
|
||||
<div>
|
||||
<label className={lbl}>Unit (optional)</label>
|
||||
<Select name="unit_id" defaultValue={expense?.unit_id ?? ""} options={unitOptions} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
name="is_recurring"
|
||||
type="checkbox"
|
||||
id="is_recurring"
|
||||
checked={isRecurring}
|
||||
onChange={(e) => setIsRecurring(e.target.checked)}
|
||||
className="h-4 w-4 rounded accent-indigo-600"
|
||||
/>
|
||||
<label htmlFor="is_recurring" className="text-sm text-white/70">Recurring expense</label>
|
||||
</div>
|
||||
|
||||
{isRecurring && (
|
||||
<div>
|
||||
<label className={lbl}>Recurrence</label>
|
||||
<Select name="recurrence" defaultValue={expense?.recurrence ?? "monthly"} options={RECURRENCE} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => router.back()} className="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/60 hover:text-white transition">Cancel</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||
{loading ? "Saving..." : expense ? "Save Changes" : "Add Expense"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { FileWarning } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface Props {
|
||||
payment: {
|
||||
id: string
|
||||
amount: number
|
||||
due_date: string
|
||||
status: string
|
||||
}
|
||||
tenant: {
|
||||
first_name: string
|
||||
last_name: string
|
||||
email?: string | null
|
||||
}
|
||||
property: { name: string; address_line1?: string; city?: string; state?: string }
|
||||
unit?: { unit_number: string } | null
|
||||
}
|
||||
|
||||
export function LateNoticeButton({ payment, tenant, property, unit }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function generateNotice() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { jsPDF } = await import("jspdf")
|
||||
const doc = new jsPDF({ unit: "pt", format: "a4" })
|
||||
const pageW = doc.internal.pageSize.getWidth()
|
||||
const margin = 60
|
||||
const today = new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })
|
||||
const daysLate = Math.floor((Date.now() - new Date(payment.due_date).getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
// Header
|
||||
doc.setFillColor(239, 68, 68)
|
||||
doc.rect(0, 0, pageW, 8, "F")
|
||||
|
||||
doc.setFontSize(11)
|
||||
doc.setFont("helvetica", "normal")
|
||||
doc.setTextColor(120, 120, 140)
|
||||
doc.text("Property Management Network", margin, 40)
|
||||
doc.text(today, pageW - margin, 40, { align: "right" })
|
||||
|
||||
// Title
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.setFontSize(20)
|
||||
doc.setTextColor(239, 68, 68)
|
||||
doc.text("LATE RENT NOTICE", margin, 90)
|
||||
|
||||
doc.setDrawColor(239, 68, 68, 0.3)
|
||||
doc.line(margin, 100, pageW - margin, 100)
|
||||
|
||||
// Tenant info
|
||||
doc.setFontSize(11)
|
||||
doc.setFont("helvetica", "normal")
|
||||
doc.setTextColor(40, 40, 60)
|
||||
doc.text(`To: ${tenant.first_name} ${tenant.last_name}`, margin, 130)
|
||||
if (tenant.email) doc.text(tenant.email, margin, 148)
|
||||
doc.text(`${property.name}${unit ? ` — Unit ${unit.unit_number}` : ""}`, margin, 166)
|
||||
if (property.address_line1) {
|
||||
doc.text(`${property.address_line1}${property.city ? `, ${property.city}` : ""}${property.state ? `, ${property.state}` : ""}`, margin, 184)
|
||||
}
|
||||
|
||||
// Body
|
||||
doc.setFontSize(11)
|
||||
doc.setTextColor(60, 60, 80)
|
||||
const body = [
|
||||
`Dear ${tenant.first_name} ${tenant.last_name},`,
|
||||
"",
|
||||
`This notice is to inform you that your rent payment of $${Number(payment.amount).toFixed(2)} was due`,
|
||||
`on ${new Date(payment.due_date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`,
|
||||
`and is now ${daysLate} day${daysLate !== 1 ? "s" : ""} past due.`,
|
||||
"",
|
||||
"Please arrange payment immediately to avoid further action. If you have already",
|
||||
"made this payment, please disregard this notice and contact your landlord.",
|
||||
"",
|
||||
"If you are experiencing financial difficulties, please contact us as soon as",
|
||||
"possible to discuss payment arrangements.",
|
||||
]
|
||||
|
||||
let y = 230
|
||||
body.forEach((line) => {
|
||||
doc.text(line, margin, y)
|
||||
y += 18
|
||||
})
|
||||
|
||||
// Payment box
|
||||
doc.setFillColor(254, 242, 242)
|
||||
doc.roundedRect(margin, y + 10, pageW - margin * 2, 70, 8, 8, "F")
|
||||
doc.setFontSize(10)
|
||||
doc.setTextColor(153, 27, 27)
|
||||
doc.text("Amount Due", margin + 20, y + 35)
|
||||
doc.setFontSize(20)
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.text(`$${Number(payment.amount).toFixed(2)}`, margin + 20, y + 62)
|
||||
doc.setFontSize(10)
|
||||
doc.setFont("helvetica", "normal")
|
||||
doc.text(`Due since: ${new Date(payment.due_date).toLocaleDateString()}`, pageW - margin - 20, y + 48, { align: "right" })
|
||||
|
||||
// Signature
|
||||
y += 120
|
||||
doc.setFontSize(10)
|
||||
doc.setTextColor(100, 100, 120)
|
||||
doc.text("Sincerely,", margin, y)
|
||||
doc.text("Property Management Network", margin, y + 20)
|
||||
doc.text("propertymanagement.network", margin, y + 36)
|
||||
|
||||
// Footer
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(180, 180, 200)
|
||||
doc.text("This is an official notice. Please retain for your records.", margin, 780)
|
||||
|
||||
const filename = `late-notice-${tenant.last_name.toLowerCase()}-${payment.due_date}.pdf`
|
||||
doc.save(filename)
|
||||
toast.success("Late notice downloaded")
|
||||
} catch {
|
||||
toast.error("Failed to generate notice")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (payment.status !== "overdue") return null
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={generateNotice}
|
||||
disabled={loading}
|
||||
title="Download Late Notice"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-500/20 px-2.5 py-1.5 text-xs text-red-400 hover:border-red-500/40 hover:bg-red-500/5 transition disabled:opacity-40"
|
||||
>
|
||||
<FileWarning className="h-3.5 w-3.5" />
|
||||
{loading ? "…" : "Notice"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useWarnUnsaved } from "@/lib/hooks/use-warn-unsaved"
|
||||
import { Select } from "@/components/ui/select"
|
||||
|
||||
const LEASE_TYPES = [
|
||||
{ value: "fixed", label: "Fixed Term" },
|
||||
{ value: "month_to_month", label: "Month-to-Month" },
|
||||
]
|
||||
|
||||
export function LeaseForm({ tenants, properties, lease, prefill }: {
|
||||
tenants: any[]; properties: any[]; lease?: any
|
||||
prefill?: { tenant_id?: string; property_id?: string; unit_id?: string; rent_amount?: string }
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [selectedTenantId, setSelectedTenantId] = useState(lease?.tenant_id ?? prefill?.tenant_id ?? "")
|
||||
const [selectedPropertyId, setSelectedPropertyId] = useState(lease?.property_id ?? prefill?.property_id ?? "")
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
useWarnUnsaved(isDirty)
|
||||
|
||||
const tenantOptions = [
|
||||
{ value: "", label: "Select tenant…" },
|
||||
...tenants.map((t: any) => ({ value: t.id, label: `${t.first_name} ${t.last_name}` })),
|
||||
]
|
||||
|
||||
const propertyOptions = [
|
||||
{ value: "", label: "Select property…" },
|
||||
...properties.map((p: any) => ({ value: p.id, label: p.name })),
|
||||
]
|
||||
|
||||
const units = properties.find((p: any) => p.id === selectedPropertyId)?.units ?? []
|
||||
const unitOptions = [
|
||||
{ value: "", label: "No unit" },
|
||||
...units.map((u: any) => ({ value: u.id, label: `Unit ${u.unit_number}` })),
|
||||
]
|
||||
|
||||
const selectedTenant = tenants.find((t: any) => t.id === selectedTenantId)
|
||||
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const lbl = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
tenant_id: fd.get("tenant_id"),
|
||||
property_id: fd.get("property_id"),
|
||||
unit_id: fd.get("unit_id") || undefined,
|
||||
lease_start: fd.get("lease_start"),
|
||||
lease_end: fd.get("lease_end"),
|
||||
rent_amount: Number(fd.get("rent_amount")),
|
||||
security_deposit: fd.get("security_deposit") ? Number(fd.get("security_deposit")) : undefined,
|
||||
lease_type: fd.get("lease_type"),
|
||||
auto_renew: fd.get("auto_renew") === "on",
|
||||
notes: fd.get("notes") || undefined,
|
||||
}
|
||||
|
||||
const res = await fetch(lease ? `/api/leases/${lease.id}` : "/api/leases", {
|
||||
method: lease ? "PATCH" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(typeof data.error === "string" ? data.error : "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
setIsDirty(false)
|
||||
toast.success(lease ? "Lease updated" : "Lease created")
|
||||
router.push("/leases")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} onChange={() => setIsDirty(true)} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && <div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Tenant <span className="text-red-400">*</span></label>
|
||||
<Select
|
||||
name="tenant_id"
|
||||
value={selectedTenantId}
|
||||
onChange={(val) => {
|
||||
setSelectedTenantId(val)
|
||||
const t = tenants.find((t: any) => t.id === val)
|
||||
if (t) setSelectedPropertyId(t.property_id ?? "")
|
||||
}}
|
||||
options={tenantOptions}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Property <span className="text-red-400">*</span></label>
|
||||
<Select
|
||||
name="property_id"
|
||||
value={selectedPropertyId}
|
||||
onChange={setSelectedPropertyId}
|
||||
options={propertyOptions}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Unit</label>
|
||||
<Select
|
||||
name="unit_id"
|
||||
defaultValue={lease?.unit_id ?? prefill?.unit_id ?? selectedTenant?.unit_id ?? ""}
|
||||
options={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Lease Start <span className="text-red-400">*</span></label>
|
||||
<input name="lease_start" type="date" required defaultValue={lease?.lease_start} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Lease End <span className="text-red-400">*</span></label>
|
||||
<input name="lease_end" type="date" required defaultValue={lease?.lease_end} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Monthly Rent ($) <span className="text-red-400">*</span></label>
|
||||
<input name="rent_amount" type="number" step="0.01" min="0" required placeholder="0.00" defaultValue={lease?.rent_amount ?? prefill?.rent_amount ?? ""} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Security Deposit ($)</label>
|
||||
<input name="security_deposit" type="number" step="0.01" min="0" placeholder="0.00" defaultValue={lease?.security_deposit ?? ""} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Lease Type</label>
|
||||
<Select name="lease_type" defaultValue={lease?.lease_type ?? "fixed"} options={LEASE_TYPES} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
<input name="auto_renew" type="checkbox" id="auto_renew" defaultChecked={lease?.auto_renew} className="h-4 w-4 rounded border-white/20 bg-white/5 accent-indigo-600" />
|
||||
<label htmlFor="auto_renew" className="text-sm text-white/70">Auto-renew</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Notes</label>
|
||||
<textarea name="notes" rows={2} placeholder="Optional..." defaultValue={lease?.notes ?? ""} className={cls + " resize-none"} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => router.back()} className="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/60 hover:text-white transition">Cancel</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||
{loading ? "Saving..." : lease ? "Save Changes" : "Add Lease"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { Camera, X, Loader2 } from "lucide-react"
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "general", label: "General" },
|
||||
{ value: "plumbing", label: "Plumbing" },
|
||||
{ value: "electrical", label: "Electrical" },
|
||||
{ value: "hvac", label: "HVAC" },
|
||||
{ value: "appliance", label: "Appliance" },
|
||||
{ value: "structural", label: "Structural" },
|
||||
{ value: "pest", label: "Pest" },
|
||||
]
|
||||
|
||||
const PRIORITIES = [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "emergency", label: "Emergency" },
|
||||
]
|
||||
|
||||
export function MaintenanceForm({ properties, tenants, request }: {
|
||||
properties: any[]; tenants: any[]; request?: any
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [selectedPropertyId, setSelectedPropertyId] = useState(request?.property_id ?? "")
|
||||
const [photos, setPhotos] = useState<string[]>(request?.images ?? [])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function handlePhotoUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
if (!files.length) return
|
||||
if (photos.length + files.length > 5) { toast.error("Max 5 photos"); return }
|
||||
|
||||
setUploading(true)
|
||||
|
||||
const uploaded: string[] = []
|
||||
for (const file of files) {
|
||||
if (file.size > 5 * 1024 * 1024) { toast.error(`${file.name} is too large (max 5MB)`); continue }
|
||||
const uploadData = new FormData()
|
||||
uploadData.append("file", file)
|
||||
uploadData.append("scope", "maintenance")
|
||||
const res = await fetch("/api/upload", { method: "POST", body: uploadData })
|
||||
if (!res.ok) { toast.error(`Failed to upload ${file.name}`); continue }
|
||||
const { url } = await res.json()
|
||||
uploaded.push(url)
|
||||
}
|
||||
setPhotos((prev) => [...prev, ...uploaded])
|
||||
setUploading(false)
|
||||
if (fileRef.current) fileRef.current.value = ""
|
||||
}
|
||||
|
||||
function removePhoto(url: string) {
|
||||
setPhotos((prev) => prev.filter((p) => p !== url))
|
||||
}
|
||||
|
||||
const propertyOptions = [
|
||||
{ value: "", label: "Select property…" },
|
||||
...properties.map((p: any) => ({ value: p.id, label: p.name })),
|
||||
]
|
||||
|
||||
const units = properties.find((p: any) => p.id === selectedPropertyId)?.units ?? []
|
||||
const unitOptions = [
|
||||
{ value: "", label: "No specific unit" },
|
||||
...units.map((u: any) => ({ value: u.id, label: `Unit ${u.unit_number}` })),
|
||||
]
|
||||
|
||||
const tenantOptions = [
|
||||
{ value: "", label: "No tenant" },
|
||||
...tenants.map((t: any) => ({ value: t.id, label: `${t.first_name} ${t.last_name}` })),
|
||||
]
|
||||
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const lbl = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
property_id: formData.get("property_id"),
|
||||
unit_id: formData.get("unit_id") || undefined,
|
||||
tenant_id: formData.get("tenant_id") || undefined,
|
||||
title: formData.get("title"),
|
||||
description: formData.get("description"),
|
||||
category: formData.get("category"),
|
||||
priority: formData.get("priority"),
|
||||
assigned_to: formData.get("assigned_to") || undefined,
|
||||
estimated_cost: formData.get("estimated_cost") ? Number(formData.get("estimated_cost")) : undefined,
|
||||
images: photos,
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
request ? `/api/maintenance/${request.id}` : "/api/maintenance",
|
||||
{ method: request ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
|
||||
)
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(typeof data.error === "string" ? data.error : "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(request ? "Request updated" : "Maintenance request created")
|
||||
router.push(`/maintenance/${data.id}`)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Title <span className="text-red-400">*</span></label>
|
||||
<input name="title" required placeholder="e.g. Leaking faucet in bathroom" defaultValue={request?.title} className={cls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Description <span className="text-red-400">*</span></label>
|
||||
<textarea name="description" required rows={3} placeholder="Describe the issue in detail..." defaultValue={request?.description} className={cls + " resize-none"} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Category</label>
|
||||
<Select name="category" defaultValue={request?.category ?? "general"} options={CATEGORIES} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Priority</label>
|
||||
<Select name="priority" defaultValue={request?.priority ?? "medium"} options={PRIORITIES} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Property <span className="text-red-400">*</span></label>
|
||||
<Select
|
||||
name="property_id"
|
||||
value={selectedPropertyId}
|
||||
onChange={setSelectedPropertyId}
|
||||
options={propertyOptions}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Unit</label>
|
||||
<Select name="unit_id" defaultValue={request?.unit_id ?? ""} options={unitOptions} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Reported by Tenant</label>
|
||||
<Select name="tenant_id" defaultValue={request?.tenant_id ?? ""} options={tenantOptions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Assigned To</label>
|
||||
<input name="assigned_to" placeholder="Contractor / vendor name" defaultValue={request?.assigned_to ?? ""} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Estimated Cost ($)</label>
|
||||
<input name="estimated_cost" type="number" step="0.01" min="0" placeholder="0.00" defaultValue={request?.estimated_cost ?? ""} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photo Upload */}
|
||||
<div>
|
||||
<label className={lbl}>Photos <span className="text-white/30 font-normal">(up to 5)</span></label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{photos.map((url) => (
|
||||
<div key={url} className="relative h-20 w-20 overflow-hidden rounded-lg border border-white/10">
|
||||
<img src={url} alt="" className="h-full w-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePhoto(url)}
|
||||
className="absolute right-0.5 top-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-black/70 text-white hover:bg-red-500/80 transition"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{photos.length < 5 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-white/10 text-white/30 hover:border-indigo-500/40 hover:text-white/50 transition disabled:opacity-50"
|
||||
>
|
||||
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
|
||||
<span className="text-[10px]">Add</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoUpload} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => router.back()} className="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/60 hover:text-white transition">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||
{loading ? "Saving..." : request ? "Save Changes" : "Create Request"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const STATUSES = [
|
||||
{ value: "open", label: "Open", color: "border-amber-500/30 text-amber-400 hover:bg-amber-500/10" },
|
||||
{ value: "in_progress", label: "In Progress", color: "border-blue-500/30 text-blue-400 hover:bg-blue-500/10" },
|
||||
{ value: "resolved", label: "Resolved", color: "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10" },
|
||||
{ value: "closed", label: "Closed", color: "border-white/10 text-white/40 hover:bg-white/5" },
|
||||
]
|
||||
|
||||
export function MaintenanceStatusUpdater({ request }: { request: any }) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [resolutionNotes, setResolutionNotes] = useState(request.resolution_notes ?? "")
|
||||
const [actualCost, setActualCost] = useState(request.actual_cost ?? "")
|
||||
|
||||
async function updateStatus(status: string) {
|
||||
setLoading(true)
|
||||
await fetch(`/api/maintenance/${request.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
status,
|
||||
resolution_notes: resolutionNotes || undefined,
|
||||
actual_cost: actualCost ? Number(actualCost) : undefined,
|
||||
}),
|
||||
})
|
||||
setLoading(false)
|
||||
toast.success("Status updated")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-4">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-white/30">Update Status</h3>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{STATUSES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => updateStatus(s.value)}
|
||||
disabled={loading || request.status === s.value}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition disabled:opacity-40 ${
|
||||
request.status === s.value
|
||||
? "opacity-40 cursor-default"
|
||||
: s.color
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-white/50">Resolution Notes</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={resolutionNotes}
|
||||
onChange={(e) => setResolutionNotes(e.target.value)}
|
||||
placeholder="Describe what was done to resolve the issue..."
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-white/50">Actual Cost ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={actualCost}
|
||||
onChange={(e) => setActualCost(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => updateStatus(request.status)}
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-indigo-600 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50 transition"
|
||||
>
|
||||
{loading ? "Saving..." : "Save Notes"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
export function PortalButton() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true)
|
||||
const res = await fetch("/api/stripe/portal", { method: "POST" })
|
||||
const data = await res.json()
|
||||
if (data.url) window.location.href = data.url
|
||||
else setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Loading..." : "Manage Subscription"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function ProfileForm({ profile }: { profile: any }) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const inputClass = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const labelClass = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
|
||||
const res = await fetch("/api/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
full_name: formData.get("full_name") as string,
|
||||
phone: (formData.get("phone") as string) || null,
|
||||
company_name: (formData.get("company_name") as string) || null,
|
||||
}),
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(typeof data.error === "string" ? data.error : "Failed to save profile")
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Profile saved")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Email</label>
|
||||
<input value={profile?.email ?? ""} disabled className={inputClass + " opacity-50 cursor-not-allowed"} readOnly />
|
||||
<p className="mt-1 text-xs text-white/30">Email cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Full Name</label>
|
||||
<input name="full_name" defaultValue={profile?.full_name ?? ""} placeholder="John Smith" className={inputClass} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Phone</label>
|
||||
<input name="phone" defaultValue={profile?.phone ?? ""} placeholder="+1 555 000 0000" className={inputClass} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Company / Business Name</label>
|
||||
<input name="company_name" defaultValue={profile?.company_name ?? ""} placeholder="Smith Property Management" className={inputClass} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-indigo-600 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition"
|
||||
>
|
||||
{loading ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useWarnUnsaved } from "@/lib/hooks/use-warn-unsaved"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import type { Property } from "@/types"
|
||||
|
||||
const PROPERTY_TYPES = [
|
||||
{ value: "residential", label: "Residential" },
|
||||
{ value: "commercial", label: "Commercial" },
|
||||
{ value: "mixed", label: "Mixed Use" },
|
||||
]
|
||||
|
||||
interface PropertyFormProps {
|
||||
property?: Property
|
||||
}
|
||||
|
||||
export function PropertyForm({ property }: PropertyFormProps) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
useWarnUnsaved(isDirty)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
name: formData.get("name"),
|
||||
address_line1: formData.get("address_line1"),
|
||||
address_line2: formData.get("address_line2") || undefined,
|
||||
city: formData.get("city"),
|
||||
state: formData.get("state") || undefined,
|
||||
postal_code: formData.get("postal_code") || undefined,
|
||||
country: formData.get("country") || "US",
|
||||
property_type: formData.get("property_type"),
|
||||
total_units: Number(formData.get("total_units")) || 1,
|
||||
notes: formData.get("notes") || undefined,
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
property ? `/api/properties/${property.id}` : "/api/properties",
|
||||
{ method: property ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
|
||||
)
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(typeof data.error === "string" ? data.error : "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
setIsDirty(false)
|
||||
toast.success(property ? "Property updated" : "Property added")
|
||||
router.push(`/properties/${data.id}`)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const lbl = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} onChange={() => setIsDirty(true)} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Property Name <span className="text-red-400">*</span></label>
|
||||
<input name="name" required placeholder="Sunset Apartments" defaultValue={property?.name} className={cls} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Type</label>
|
||||
<Select
|
||||
name="property_type"
|
||||
defaultValue={property?.property_type ?? "residential"}
|
||||
options={PROPERTY_TYPES}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Total Units</label>
|
||||
<input name="total_units" type="number" min="1" defaultValue={String(property?.total_units ?? 1)} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Address Line 1 <span className="text-red-400">*</span></label>
|
||||
<input name="address_line1" required placeholder="123 Main Street" defaultValue={property?.address_line1} className={cls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Address Line 2</label>
|
||||
<input name="address_line2" placeholder="Apt, Suite, Floor (optional)" defaultValue={property?.address_line2 ?? ""} className={cls} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>City <span className="text-red-400">*</span></label>
|
||||
<input name="city" required placeholder="New York" defaultValue={property?.city} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>State</label>
|
||||
<input name="state" placeholder="NY" defaultValue={property?.state ?? ""} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Postal Code</label>
|
||||
<input name="postal_code" placeholder="10001" defaultValue={property?.postal_code ?? ""} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Country</label>
|
||||
<input name="country" defaultValue={property?.country ?? "US"} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Notes</label>
|
||||
<textarea name="notes" placeholder="Optional notes..." defaultValue={property?.notes ?? ""} rows={3} className={cls + " resize-none"} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => router.back()} className="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/60 hover:text-white transition">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||
{loading ? "Saving..." : property ? "Save Changes" : "Add Property"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Camera, Loader2, X } from "lucide-react"
|
||||
|
||||
interface Props {
|
||||
propertyId: string
|
||||
currentImageUrl?: string | null
|
||||
}
|
||||
|
||||
export function PropertyPhotoUpload({ propertyId, currentImageUrl }: Props) {
|
||||
const router = useRouter()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [preview, setPreview] = useState<string | null>(currentImageUrl ?? null)
|
||||
|
||||
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("Please select an image file")
|
||||
return
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error("Image must be under 5MB")
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
// Upload to local storage via the gated upload endpoint
|
||||
const uploadData = new FormData()
|
||||
uploadData.append("file", file)
|
||||
uploadData.append("scope", "property-images")
|
||||
uploadData.append("fixed_name", propertyId)
|
||||
|
||||
const uploadRes = await fetch("/api/upload", { method: "POST", body: uploadData })
|
||||
if (!uploadRes.ok) throw new Error("Upload failed")
|
||||
const { url: publicUrl } = await uploadRes.json()
|
||||
|
||||
// Update property record
|
||||
const res = await fetch(`/api/properties/${propertyId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_url: publicUrl }),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("Failed to save image URL")
|
||||
|
||||
setPreview(publicUrl)
|
||||
toast.success("Photo updated")
|
||||
router.refresh()
|
||||
} catch (err: any) {
|
||||
toast.error(err.message ?? "Upload failed")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removePhoto() {
|
||||
setUploading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/properties/${propertyId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_url: null }),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to remove photo")
|
||||
setPreview(null)
|
||||
toast.success("Photo removed")
|
||||
router.refresh()
|
||||
} catch {
|
||||
toast.error("Failed to remove photo")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{preview ? (
|
||||
<div className="group relative h-44 w-full overflow-hidden rounded-xl border border-white/[0.06]">
|
||||
<img src={preview} alt="Property" className="h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/50 opacity-0 group-hover:opacity-100 transition">
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-white/10 px-3 py-2 text-xs text-white hover:bg-white/20 transition"
|
||||
>
|
||||
<Camera className="h-3.5 w-3.5" /> Change
|
||||
</button>
|
||||
<button
|
||||
onClick={removePhoto}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-red-500/20 px-3 py-2 text-xs text-red-400 hover:bg-red-500/30 transition"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" /> Remove
|
||||
</button>
|
||||
</div>
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex h-44 w-full flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-white/10 bg-white/[0.02] text-white/30 hover:border-indigo-500/40 hover:text-white/50 transition disabled:opacity-50"
|
||||
>
|
||||
{uploading
|
||||
? <Loader2 className="h-6 w-6 animate-spin" />
|
||||
: <>
|
||||
<Camera className="h-6 w-6" />
|
||||
<span className="text-xs">Add property photo</span>
|
||||
</>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function RentActions({ payment }: { payment: any }) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function markAs(status: string) {
|
||||
setLoading(true)
|
||||
await fetch(`/api/rent/${payment.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
status,
|
||||
paid_date: status === "paid" ? new Date().toISOString().slice(0, 10) : null,
|
||||
}),
|
||||
})
|
||||
setLoading(false)
|
||||
toast.success(status === "paid" ? "Marked as paid" : "Marked as overdue")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setLoading(true)
|
||||
await fetch(`/api/rent/${payment.id}`, { method: "DELETE" })
|
||||
setLoading(false)
|
||||
toast.success("Payment deleted")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
if (loading) return <span className="text-xs text-white/30">Updating...</span>
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition">
|
||||
{payment.status !== "paid" && (
|
||||
<button onClick={() => markAs("paid")} className="text-xs text-emerald-400 hover:text-emerald-300">
|
||||
Mark Paid
|
||||
</button>
|
||||
)}
|
||||
{payment.status !== "overdue" && payment.status !== "paid" && (
|
||||
<button onClick={() => markAs("overdue")} className="text-xs text-red-400 hover:text-red-300">
|
||||
Mark Overdue
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleDelete} className="text-xs text-white/30 hover:text-red-400">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Select } from "@/components/ui/select"
|
||||
|
||||
const STATUSES = [
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "paid", label: "Paid" },
|
||||
{ value: "overdue", label: "Overdue" },
|
||||
{ value: "partial", label: "Partial" },
|
||||
{ value: "waived", label: "Waived" },
|
||||
]
|
||||
|
||||
const PAYMENT_METHODS = [
|
||||
{ value: "", label: "Select method…" },
|
||||
{ value: "cash", label: "Cash" },
|
||||
{ value: "bank_transfer", label: "Bank Transfer" },
|
||||
{ value: "check", label: "Check" },
|
||||
{ value: "stripe", label: "Stripe" },
|
||||
]
|
||||
|
||||
export function RentPaymentForm({ tenants }: { tenants: any[] }) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [selectedTenantId, setSelectedTenantId] = useState("")
|
||||
|
||||
const tenantOptions = [
|
||||
{ value: "", label: "Select tenant…" },
|
||||
...tenants.map((t: any) => ({
|
||||
value: t.id,
|
||||
label: `${t.first_name} ${t.last_name}${t.property?.name ? ` — ${t.property.name}` : ""}${t.unit ? ` (Unit ${t.unit.unit_number})` : ""}`,
|
||||
})),
|
||||
]
|
||||
|
||||
const selectedTenant = tenants.find((t: any) => t.id === selectedTenantId)
|
||||
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const lbl = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
tenant_id: formData.get("tenant_id"),
|
||||
property_id: selectedTenant?.property?.id,
|
||||
unit_id: selectedTenant?.unit_id || undefined,
|
||||
amount: Number(formData.get("amount")),
|
||||
due_date: formData.get("due_date"),
|
||||
paid_date: formData.get("paid_date") || undefined,
|
||||
status: formData.get("status"),
|
||||
payment_method: formData.get("payment_method") || undefined,
|
||||
notes: formData.get("notes") || undefined,
|
||||
}
|
||||
|
||||
const res = await fetch("/api/rent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(typeof data.error === "string" ? data.error : "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Payment recorded")
|
||||
router.push("/rent")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Tenant <span className="text-red-400">*</span></label>
|
||||
<Select
|
||||
name="tenant_id"
|
||||
value={selectedTenantId}
|
||||
onChange={setSelectedTenantId}
|
||||
options={tenantOptions}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Amount ($) <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
placeholder="0.00"
|
||||
defaultValue={selectedTenant?.unit?.rent_amount ?? ""}
|
||||
className={cls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Status <span className="text-red-400">*</span></label>
|
||||
<Select name="status" defaultValue="pending" options={STATUSES} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Due Date <span className="text-red-400">*</span></label>
|
||||
<input name="due_date" type="date" required defaultValue={today} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Paid Date</label>
|
||||
<input name="paid_date" type="date" className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Payment Method</label>
|
||||
<Select name="payment_method" defaultValue="" options={PAYMENT_METHODS} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Notes</label>
|
||||
<textarea name="notes" rows={2} placeholder="Optional..." className={cls + " resize-none"} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => router.back()} className="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/60 hover:text-white transition">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||
{loading ? "Saving..." : "Record Payment"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { FileDown } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface ReceiptProps {
|
||||
payment: {
|
||||
id: string
|
||||
amount: number
|
||||
due_date: string
|
||||
paid_date: string | null
|
||||
payment_method: string | null
|
||||
status: string
|
||||
}
|
||||
tenant: {
|
||||
first_name: string
|
||||
last_name: string
|
||||
email?: string | null
|
||||
}
|
||||
property: { name: string }
|
||||
unit?: { unit_number: string } | null
|
||||
}
|
||||
|
||||
export function RentReceiptButton({ payment, tenant, property, unit }: ReceiptProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function downloadReceipt() {
|
||||
if (payment.status !== "paid") {
|
||||
toast.error("Receipt only available for paid payments")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const { jsPDF } = await import("jspdf")
|
||||
const doc = new jsPDF({ unit: "pt", format: "a4" })
|
||||
|
||||
const pageW = doc.internal.pageSize.getWidth()
|
||||
const margin = 48
|
||||
|
||||
// Header background
|
||||
doc.setFillColor(22, 22, 31)
|
||||
doc.rect(0, 0, pageW, 100, "F")
|
||||
|
||||
// Title
|
||||
doc.setTextColor(255, 255, 255)
|
||||
doc.setFontSize(22)
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.text("Property Management Network", margin, 44)
|
||||
|
||||
doc.setFontSize(11)
|
||||
doc.setFont("helvetica", "normal")
|
||||
doc.setTextColor(160, 160, 180)
|
||||
doc.text("RENT RECEIPT", margin, 64)
|
||||
|
||||
// Receipt number
|
||||
doc.setTextColor(160, 160, 180)
|
||||
doc.setFontSize(9)
|
||||
doc.text(`Receipt #${payment.id.slice(0, 8).toUpperCase()}`, pageW - margin, 44, { align: "right" })
|
||||
doc.text(new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }), pageW - margin, 60, { align: "right" })
|
||||
|
||||
// Divider
|
||||
doc.setDrawColor(60, 60, 80)
|
||||
doc.line(margin, 112, pageW - margin, 112)
|
||||
|
||||
// Tenant & Property info
|
||||
doc.setTextColor(120, 120, 140)
|
||||
doc.setFontSize(9)
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.text("TENANT", margin, 136)
|
||||
doc.text("PROPERTY", pageW / 2, 136)
|
||||
|
||||
doc.setFont("helvetica", "normal")
|
||||
doc.setTextColor(30, 30, 30)
|
||||
doc.setFontSize(11)
|
||||
doc.text(`${tenant.first_name} ${tenant.last_name}`, margin, 154)
|
||||
doc.text(property.name, pageW / 2, 154)
|
||||
|
||||
if (tenant.email) {
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(120, 120, 140)
|
||||
doc.text(tenant.email, margin, 170)
|
||||
}
|
||||
if (unit) {
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(120, 120, 140)
|
||||
doc.text(`Unit ${unit.unit_number}`, pageW / 2, 170)
|
||||
}
|
||||
|
||||
// Payment box
|
||||
doc.setFillColor(245, 245, 250)
|
||||
doc.roundedRect(margin, 200, pageW - margin * 2, 140, 8, 8, "F")
|
||||
|
||||
doc.setTextColor(30, 30, 30)
|
||||
doc.setFontSize(13)
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.text("Payment Details", margin + 20, 228)
|
||||
|
||||
const rows = [
|
||||
["Amount Paid", `$${Number(payment.amount).toFixed(2)}`],
|
||||
["Due Date", payment.due_date],
|
||||
["Paid Date", payment.paid_date ?? "—"],
|
||||
["Payment Method", payment.payment_method ?? "—"],
|
||||
["Status", "PAID"],
|
||||
]
|
||||
|
||||
doc.setFont("helvetica", "normal")
|
||||
doc.setFontSize(10)
|
||||
rows.forEach(([label, value], i) => {
|
||||
const y = 252 + i * 18
|
||||
doc.setTextColor(100, 100, 120)
|
||||
doc.text(label, margin + 20, y)
|
||||
doc.setTextColor(30, 30, 30)
|
||||
doc.text(value, pageW - margin - 20, y, { align: "right" })
|
||||
})
|
||||
|
||||
// Paid stamp
|
||||
doc.setTextColor(34, 197, 94)
|
||||
doc.setFontSize(32)
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.text("PAID", pageW - margin - 20, 290, { align: "right" })
|
||||
|
||||
// Footer
|
||||
doc.setFont("helvetica", "normal")
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(160, 160, 180)
|
||||
doc.text("This receipt was generated by Property Management Network. Please keep for your records.", margin, 780)
|
||||
doc.text("propertymanagement.network", pageW - margin, 780, { align: "right" })
|
||||
|
||||
const filename = `receipt-${tenant.last_name.toLowerCase()}-${payment.due_date}.pdf`
|
||||
doc.save(filename)
|
||||
toast.success("Receipt downloaded")
|
||||
} catch {
|
||||
toast.error("Failed to generate receipt")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (payment.status !== "paid") return null
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={downloadReceipt}
|
||||
disabled={loading}
|
||||
title="Download Receipt"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1.5 text-xs text-white/60 hover:border-indigo-500/30 hover:text-indigo-400 transition disabled:opacity-40"
|
||||
>
|
||||
<FileDown className="h-3.5 w-3.5" />
|
||||
{loading ? "…" : "Receipt"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useWarnUnsaved } from "@/lib/hooks/use-warn-unsaved"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import type { Tenant } from "@/types"
|
||||
|
||||
interface TenantFormProps {
|
||||
properties: { id: string; name: string; units: { id: string; unit_number: string; status: string }[] }[]
|
||||
tenant?: Tenant
|
||||
}
|
||||
|
||||
export function TenantForm({ properties, tenant }: TenantFormProps) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [selectedPropertyId, setSelectedPropertyId] = useState(tenant?.property_id ?? "")
|
||||
useWarnUnsaved(isDirty)
|
||||
|
||||
const propertyOptions = [
|
||||
{ value: "", label: "Select property…" },
|
||||
...properties.map((p) => ({ value: p.id, label: p.name })),
|
||||
]
|
||||
|
||||
const availableUnits = properties
|
||||
.find((p) => p.id === selectedPropertyId)
|
||||
?.units.filter((u) => u.status === "vacant" || u.id === tenant?.unit_id) ?? []
|
||||
|
||||
const unitOptions = [
|
||||
{ value: "", label: "No unit assigned" },
|
||||
...availableUnits.map((u) => ({ value: u.id, label: `Unit ${u.unit_number}` })),
|
||||
]
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const body: Record<string, unknown> = {
|
||||
property_id: formData.get("property_id"),
|
||||
unit_id: formData.get("unit_id") || undefined,
|
||||
first_name: formData.get("first_name"),
|
||||
last_name: formData.get("last_name"),
|
||||
email: formData.get("email") || undefined,
|
||||
phone: formData.get("phone") || undefined,
|
||||
emergency_contact_name: formData.get("emergency_contact_name") || undefined,
|
||||
emergency_contact_phone: formData.get("emergency_contact_phone") || undefined,
|
||||
move_in_date: formData.get("move_in_date") || undefined,
|
||||
notes: formData.get("notes") || undefined,
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
tenant ? `/api/tenants/${tenant.id}` : "/api/tenants",
|
||||
{ method: tenant ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
|
||||
)
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(typeof data.error === "string" ? data.error : "Something went wrong")
|
||||
return
|
||||
}
|
||||
|
||||
setIsDirty(false)
|
||||
toast.success(tenant ? "Tenant updated" : "Tenant added")
|
||||
router.push(`/tenants/${data.id}`)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const lbl = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} onChange={() => setIsDirty(true)} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>First Name <span className="text-red-400">*</span></label>
|
||||
<input name="first_name" required placeholder="John" defaultValue={tenant?.first_name} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Last Name <span className="text-red-400">*</span></label>
|
||||
<input name="last_name" required placeholder="Smith" defaultValue={tenant?.last_name} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Email</label>
|
||||
<input name="email" type="email" placeholder="john@email.com" defaultValue={tenant?.email ?? ""} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Phone</label>
|
||||
<input name="phone" placeholder="+1 555 000 0000" defaultValue={tenant?.phone ?? ""} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Property <span className="text-red-400">*</span></label>
|
||||
<Select
|
||||
name="property_id"
|
||||
value={selectedPropertyId}
|
||||
onChange={setSelectedPropertyId}
|
||||
options={propertyOptions}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedPropertyId && (
|
||||
<div>
|
||||
<label className={lbl}>Unit</label>
|
||||
<Select name="unit_id" defaultValue={tenant?.unit_id ?? ""} options={unitOptions} />
|
||||
{availableUnits.length === 0 && (
|
||||
<p className="mt-1 text-xs text-amber-400">No vacant units in this property.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Move-in Date</label>
|
||||
<input name="move_in_date" type="date" defaultValue={tenant?.move_in_date ?? ""} className={cls} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/[0.06] pt-5">
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-wider text-white/30">Emergency Contact</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={lbl}>Name</label>
|
||||
<input name="emergency_contact_name" placeholder="Jane Smith" defaultValue={tenant?.emergency_contact_name ?? ""} className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={lbl}>Phone</label>
|
||||
<input name="emergency_contact_phone" placeholder="+1 555 000 0001" defaultValue={tenant?.emergency_contact_phone ?? ""} className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={lbl}>Notes</label>
|
||||
<textarea name="notes" rows={3} placeholder="Optional..." defaultValue={tenant?.notes ?? ""} className={cls + " resize-none"} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => router.back()} className="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/60 hover:text-white transition">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||
{loading ? "Saving..." : tenant ? "Save Changes" : "Add Tenant"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Select } from "@/components/ui/select"
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "vacant", label: "Vacant" },
|
||||
{ value: "occupied", label: "Occupied" },
|
||||
{ value: "maintenance", label: "Maintenance" },
|
||||
{ value: "unavailable", label: "Unavailable" },
|
||||
]
|
||||
|
||||
interface UnitFormProps {
|
||||
propertyId: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [status, setStatus] = useState("vacant")
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
property_id: propertyId,
|
||||
unit_number: formData.get("unit_number"),
|
||||
bedrooms: Number(formData.get("bedrooms") ?? 1),
|
||||
bathrooms: Number(formData.get("bathrooms") ?? 1),
|
||||
sq_ft: formData.get("sq_ft") ? Number(formData.get("sq_ft")) : undefined,
|
||||
rent_amount: Number(formData.get("rent_amount")),
|
||||
status,
|
||||
notes: formData.get("notes") || undefined,
|
||||
}
|
||||
|
||||
const res = await fetch("/api/units", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
toast.error(data?.error ?? "Failed to add unit")
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Unit added")
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
} else {
|
||||
router.push(`/properties/${propertyId}`)
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass = "w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500"
|
||||
const labelClass = "block text-sm font-medium text-white/70 mb-1.5"
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Unit Number *</label>
|
||||
<input name="unit_number" required placeholder="e.g. 1A, 101" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Monthly Rent ($) *</label>
|
||||
<input name="rent_amount" type="number" required min={0} step={0.01} placeholder="1500" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Bedrooms</label>
|
||||
<input name="bedrooms" type="number" min={0} defaultValue={1} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Bathrooms</label>
|
||||
<input name="bathrooms" type="number" min={0} step={0.5} defaultValue={1} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Sq Ft</label>
|
||||
<input name="sq_ft" type="number" min={0} placeholder="Optional" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<Select
|
||||
options={statusOptions}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Notes</label>
|
||||
<textarea name="notes" rows={3} placeholder="Optional notes..." className={`${inputClass} resize-none`} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="flex-1 rounded-lg border border-white/10 py-2.5 text-sm text-white/60 hover:border-white/20 hover:text-white transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-lg bg-indigo-600 py-2.5 text-sm font-medium text-white hover:bg-indigo-500 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Adding…" : "Add Unit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { ArrowRight, CheckCircle2 } from "lucide-react"
|
||||
|
||||
export function CtaBanner() {
|
||||
return (
|
||||
<section className="mx-auto max-w-7xl px-4 sm:px-6 pb-16 sm:pb-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="relative overflow-hidden rounded-3xl border border-indigo-500/20 bg-gradient-to-br from-indigo-600/20 via-[#16161f] to-violet-600/20 p-8 sm:p-12 text-center"
|
||||
>
|
||||
{/* Glow blobs */}
|
||||
<div className="absolute -top-20 left-1/2 -translate-x-1/2 h-60 w-60 rounded-full bg-indigo-600/30 blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-20 left-1/3 h-40 w-40 rounded-full bg-violet-600/20 blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-10 right-1/3 h-40 w-40 rounded-full bg-indigo-600/20 blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400 mb-4">Get started today</p>
|
||||
<h2 className="text-2xl sm:text-4xl lg:text-5xl font-bold text-white">
|
||||
Stop managing rentals<br />the hard way
|
||||
</h2>
|
||||
<p className="mt-5 text-base sm:text-lg text-white/60 max-w-xl mx-auto">
|
||||
Join 200+ landlords who replaced their spreadsheets with Property Management Network. Free plan, no credit card.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/signup"
|
||||
className="flex w-full sm:w-auto items-center justify-center gap-2 rounded-xl bg-indigo-600 px-6 sm:px-8 py-3.5 sm:py-4 text-sm font-semibold text-white hover:bg-indigo-500 transition-all hover:shadow-xl hover:shadow-indigo-500/30 hover:-translate-y-0.5"
|
||||
>
|
||||
Create your free account <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="w-full sm:w-auto rounded-xl border border-white/10 px-6 sm:px-8 py-3.5 sm:py-4 text-sm font-medium text-white/60 hover:text-white hover:bg-white/[0.05] transition text-center"
|
||||
>
|
||||
Already have an account?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-x-4 sm:gap-x-6 gap-y-2 text-xs text-white/40">
|
||||
{[
|
||||
"Free plan forever",
|
||||
"30-day money-back on paid plans",
|
||||
"No credit card required",
|
||||
"Cancel anytime",
|
||||
].map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
|
||||
{t}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { motion, useMotionValue, useSpring } from "framer-motion"
|
||||
|
||||
export function CursorGlow() {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const mouseX = useMotionValue(-400)
|
||||
const mouseY = useMotionValue(-400)
|
||||
|
||||
const springX = useSpring(mouseX, { stiffness: 80, damping: 20, mass: 0.5 })
|
||||
const springY = useSpring(mouseY, { stiffness: 80, damping: 20, mass: 0.5 })
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
mouseX.set(e.clientX)
|
||||
mouseY.set(e.clientY)
|
||||
if (!visible) setVisible(true)
|
||||
}
|
||||
window.addEventListener("mousemove", handler, { passive: true })
|
||||
return () => window.removeEventListener("mousemove", handler)
|
||||
}, [visible, mouseX, mouseY])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-[10] hidden lg:block overflow-hidden">
|
||||
<motion.div
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: 500,
|
||||
height: 500,
|
||||
x: springX,
|
||||
y: springY,
|
||||
translateX: "-50%",
|
||||
translateY: "-50%",
|
||||
background: "radial-gradient(circle, rgba(99,102,241,0.07) 0%, rgba(139,92,246,0.04) 40%, transparent 70%)",
|
||||
opacity: visible ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Plus, Minus } from "lucide-react"
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "Is there really a free plan?",
|
||||
a: "Yes — the Starter plan is free forever. You get 1 property, up to 3 tenants, rent tracking, maintenance requests, and lease management. No credit card needed to sign up.",
|
||||
},
|
||||
{
|
||||
q: "What happens when my trial ends?",
|
||||
a: "There's no trial — paid plans start immediately when you upgrade. If you're on the free Starter plan, it never expires. You upgrade when you outgrow it.",
|
||||
},
|
||||
{
|
||||
q: "Can I cancel anytime?",
|
||||
a: "Yes. Cancel any time from your billing portal — no questions, no fees. Your data stays accessible for 30 days after cancellation so you can export everything.",
|
||||
},
|
||||
{
|
||||
q: "Do tenants need to create an account?",
|
||||
a: "No. Each tenant gets a unique private link to their portal — no account creation, no password. They can view rent history and submit maintenance requests instantly.",
|
||||
},
|
||||
{
|
||||
q: "Does Property Management Network handle actual rent collection?",
|
||||
a: "Yes — the Pro and Landlord plans include Stripe payment link generation. You can create a payment link for each tenant and they pay directly via card or bank transfer.",
|
||||
},
|
||||
{
|
||||
q: "Is my data secure?",
|
||||
a: "Yes. All data is stored in Supabase with row-level security (RLS) — meaning landlords only ever see their own data, and tenants only see their own records. All data is encrypted at rest and in transit.",
|
||||
},
|
||||
{
|
||||
q: "Can I manage multiple properties?",
|
||||
a: "The Starter plan supports 1 property. Pro supports up to 10. Landlord and Lifetime plans support unlimited properties and units.",
|
||||
},
|
||||
{
|
||||
q: "What's included in the Lifetime deal?",
|
||||
a: "The Lifetime plan is a one-time payment of $199. You get everything in the Landlord plan — unlimited properties, tenants, 25GB storage, AI calls, team access — with no recurring fees, ever. All future updates included.",
|
||||
},
|
||||
]
|
||||
|
||||
export function FAQ() {
|
||||
const [open, setOpen] = useState<number | null>(null)
|
||||
|
||||
return (
|
||||
<section id="faq" className="mx-auto max-w-3xl px-4 sm:px-6 py-16 sm:py-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400 mb-3">FAQ</p>
|
||||
<h2 className="text-3xl font-bold text-white">Common questions</h2>
|
||||
<p className="mt-3 text-white/50">Everything you need to know before signing up.</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{FAQS.map((faq, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpen(open === i ? null : i)}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-left"
|
||||
>
|
||||
<span className="text-sm font-medium text-white pr-4">{faq.q}</span>
|
||||
<div className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition-colors ${
|
||||
open === i ? "border-indigo-500/40 bg-indigo-500/10" : "border-white/10"
|
||||
}`}>
|
||||
{open === i
|
||||
? <Minus className="h-3 w-3 text-indigo-400" />
|
||||
: <Plus className="h-3 w-3 text-white/50" />
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open === i && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeInOut" }}
|
||||
>
|
||||
<p className="px-5 pb-5 text-sm text-white/50 leading-relaxed border-t border-white/[0.04] pt-3">
|
||||
{faq.a}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { CreditCard, Wrench, FileText, TrendingUp, Bell, Shield, Building2, Users } from "lucide-react"
|
||||
|
||||
const fadeUp = (delay = 0) => ({
|
||||
initial: { opacity: 0, y: 24 },
|
||||
whileInView: { opacity: 1, y: 0 },
|
||||
viewport: { once: true },
|
||||
transition: { duration: 0.5, delay },
|
||||
})
|
||||
|
||||
// 8 cards — 2 large (col-span-2) + 4 small + 2 large on last row = clean 4-col grid
|
||||
const BENTO = [
|
||||
// Row 1: [Large 2] [Small 1] [Small 1]
|
||||
{
|
||||
icon: CreditCard,
|
||||
title: "Rent Tracking",
|
||||
body: "Log every payment, set due dates, and automatically mark overdue rent. Know exactly who has paid and who hasn't — at a glance.",
|
||||
cols: "lg:col-span-2",
|
||||
gradient: "from-indigo-600/30 via-indigo-600/10 to-transparent",
|
||||
border: "border-indigo-500/30",
|
||||
iconBg: "bg-indigo-500/20",
|
||||
iconColor: "text-indigo-400",
|
||||
glow: "group-hover:shadow-indigo-500/10",
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: "Maintenance Portal",
|
||||
body: "Tenants submit requests via their unique portal — no account needed. You track everything from open to resolved.",
|
||||
cols: "lg:col-span-1",
|
||||
gradient: "from-amber-600/25 via-amber-600/10 to-transparent",
|
||||
border: "border-amber-500/30",
|
||||
iconBg: "bg-amber-500/20",
|
||||
iconColor: "text-amber-400",
|
||||
glow: "group-hover:shadow-amber-500/10",
|
||||
},
|
||||
{
|
||||
icon: Bell,
|
||||
title: "Automated Reminders",
|
||||
body: "Rent due emails go out automatically. Tenants pay on time without you chasing.",
|
||||
cols: "lg:col-span-1",
|
||||
gradient: "from-emerald-600/25 via-emerald-600/10 to-transparent",
|
||||
border: "border-emerald-500/30",
|
||||
iconBg: "bg-emerald-500/20",
|
||||
iconColor: "text-emerald-400",
|
||||
glow: "group-hover:shadow-emerald-500/10",
|
||||
},
|
||||
// Row 2: [Small 1] [Large 2] [Small 1]
|
||||
{
|
||||
icon: FileText,
|
||||
title: "Lease Management",
|
||||
body: "Track lease dates, get 60-day expiry alerts, and renew with one click — fully pre-filled from the previous lease.",
|
||||
cols: "lg:col-span-1",
|
||||
gradient: "from-blue-600/25 via-blue-600/10 to-transparent",
|
||||
border: "border-blue-500/30",
|
||||
iconBg: "bg-blue-500/20",
|
||||
iconColor: "text-blue-400",
|
||||
glow: "group-hover:shadow-blue-500/10",
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: "Expense Reports & CSV Export",
|
||||
body: "Categorise expenses per property, see your P&L, and export to CSV for your accountant in one click. All properties, all time.",
|
||||
cols: "lg:col-span-2",
|
||||
gradient: "from-violet-600/30 via-violet-600/10 to-transparent",
|
||||
border: "border-violet-500/30",
|
||||
iconBg: "bg-violet-500/20",
|
||||
iconColor: "text-violet-400",
|
||||
glow: "group-hover:shadow-violet-500/10",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Secure by Default",
|
||||
body: "Row-level security on every table. Tenants only see their own data — always.",
|
||||
cols: "lg:col-span-1",
|
||||
gradient: "from-rose-600/25 via-rose-600/10 to-transparent",
|
||||
border: "border-rose-500/30",
|
||||
iconBg: "bg-rose-500/20",
|
||||
iconColor: "text-rose-400",
|
||||
glow: "group-hover:shadow-rose-500/10",
|
||||
},
|
||||
// Row 3: [Large 2] [Large 2] — fills the row perfectly
|
||||
{
|
||||
icon: Building2,
|
||||
title: "Multi-Property Management",
|
||||
body: "One dashboard for all your properties and units. Switch between portfolios instantly — no per-property fees on the plans that matter.",
|
||||
cols: "lg:col-span-2",
|
||||
gradient: "from-cyan-600/25 via-cyan-600/10 to-transparent",
|
||||
border: "border-cyan-500/30",
|
||||
iconBg: "bg-cyan-500/20",
|
||||
iconColor: "text-cyan-400",
|
||||
glow: "group-hover:shadow-cyan-500/10",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: "Tenant Self-Service Portal",
|
||||
body: "Every tenant gets a private link to view their rent history and submit maintenance requests — no login, no app, no friction.",
|
||||
cols: "lg:col-span-2",
|
||||
gradient: "from-fuchsia-600/25 via-fuchsia-600/10 to-transparent",
|
||||
border: "border-fuchsia-500/30",
|
||||
iconBg: "bg-fuchsia-500/20",
|
||||
iconColor: "text-fuchsia-400",
|
||||
glow: "group-hover:shadow-fuchsia-500/10",
|
||||
},
|
||||
]
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<section id="features" className="mx-auto max-w-7xl px-4 sm:px-6 py-16 sm:py-24">
|
||||
<motion.div {...fadeUp()} className="text-center mb-14">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400 mb-3">Features</p>
|
||||
<h2 className="text-3xl font-bold text-white sm:text-4xl">Everything you need — nothing you don't</h2>
|
||||
<p className="mt-4 text-white/50 max-w-xl mx-auto">
|
||||
Built specifically for independent landlords. No enterprise bloat, no per-unit fees.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Bento grid — 4 columns, clean row fills */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{BENTO.map((card, i) => (
|
||||
<motion.div
|
||||
key={card.title}
|
||||
{...fadeUp(i * 0.06)}
|
||||
whileHover={{ scale: 1.02, y: -3 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||
className={`relative rounded-2xl border bg-[#16161f] bg-gradient-to-br p-6 overflow-hidden group cursor-default shadow-lg transition-shadow duration-300
|
||||
${card.border} ${card.gradient} ${card.cols} ${card.glow}
|
||||
`}
|
||||
>
|
||||
{/* Subtle inner glow on hover */}
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 bg-gradient-to-br from-white/[0.04] to-transparent rounded-2xl" />
|
||||
|
||||
<div className={`mb-4 flex h-11 w-11 items-center justify-center rounded-xl ${card.iconBg} relative`}>
|
||||
<card.icon className={`h-5 w-5 ${card.iconColor}`} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-white mb-2 text-[15px]">{card.title}</h3>
|
||||
<p className="text-sm text-white/55 leading-relaxed">{card.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { ArrowRight, X } from "lucide-react"
|
||||
|
||||
export function FloatingCTA() {
|
||||
const [show, setShow] = useState(false)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!dismissed) setShow(window.scrollY > 700)
|
||||
}
|
||||
window.addEventListener("scroll", handler, { passive: true })
|
||||
return () => window.removeEventListener("scroll", handler)
|
||||
}, [dismissed])
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 z-[80]">
|
||||
<AnimatePresence>
|
||||
{show && !dismissed && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 16, scale: 0.9 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className="relative flex items-center gap-2 sm:gap-3 rounded-2xl border border-indigo-500/30 bg-indigo-600 px-4 sm:px-5 py-3 shadow-2xl shadow-indigo-500/30"
|
||||
>
|
||||
<button
|
||||
onClick={() => { setDismissed(true); setShow(false) }}
|
||||
className="absolute -top-2 -right-2 flex h-5 w-5 items-center justify-center rounded-full border border-white/20 bg-[#16161f] text-white/50 hover:text-white transition"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-white">Start free today</p>
|
||||
<p className="text-[10px] text-indigo-200">No credit card required</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/signup"
|
||||
className="flex items-center gap-1.5 rounded-xl bg-white px-4 py-2 text-xs font-bold text-indigo-600 hover:bg-indigo-50 transition-all hover:gap-2.5 whitespace-nowrap"
|
||||
>
|
||||
Get started <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Link from "next/link"
|
||||
import { Building2, GitFork, Mail, ExternalLink } from "lucide-react"
|
||||
|
||||
const LINKS = {
|
||||
Product: [
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "Pricing", href: "#pricing" },
|
||||
{ label: "How it works", href: "#how-it-works" },
|
||||
{ label: "FAQ", href: "#faq" },
|
||||
],
|
||||
Platform: [
|
||||
{ label: "Dashboard", href: "/login" },
|
||||
{ label: "Tenant portal", href: "/tenant-portal-info" },
|
||||
{ label: "API docs", href: "/api-docs" },
|
||||
{ label: "Status", href: "/status" },
|
||||
],
|
||||
Legal: [
|
||||
{ label: "Privacy policy", href: "/privacy" },
|
||||
{ label: "Terms of service", href: "/terms" },
|
||||
{ label: "Cookie policy", href: "/cookie-policy" },
|
||||
{ label: "GDPR", href: "/gdpr" },
|
||||
],
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-white/[0.06] bg-[#09090b]">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-12 sm:py-14">
|
||||
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{/* Brand */}
|
||||
<div className="lg:col-span-2">
|
||||
<Link href="/" className="flex items-center gap-2 mb-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600">
|
||||
<Building2 className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-white">Property Management Network</span>
|
||||
</Link>
|
||||
<p className="text-sm text-white/40 max-w-xs leading-relaxed">
|
||||
The property management platform built for independent landlords who want simplicity, not complexity.
|
||||
</p>
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
{[
|
||||
{ icon: ExternalLink, href: "https://twitter.com/propertymgmtnet", label: "Twitter" },
|
||||
{ icon: GitFork, href: "https://github.com/propertymanagement-network", label: "GitHub" },
|
||||
{ icon: Mail, href: "mailto:support@propertymanagement.network", label: "Email" },
|
||||
].map(({ icon: Icon, href, label }) => (
|
||||
<a key={label} href={href} aria-label={label}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 text-white/40 hover:text-white hover:border-white/20 transition">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link columns */}
|
||||
{Object.entries(LINKS).map(([section, links]) => (
|
||||
<div key={section}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-white/30 mb-4">{section}</p>
|
||||
<ul className="space-y-2.5">
|
||||
{links.map((l) => (
|
||||
<li key={l.label}>
|
||||
<Link href={l.href} className="text-sm text-white/50 hover:text-white transition">
|
||||
{l.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-between gap-4 border-t border-white/[0.06] pt-8 text-xs text-white/30">
|
||||
<p>© {new Date().getFullYear()} Property Management Network. All rights reserved.</p>
|
||||
<p>Built with Next.js · Supabase · Stripe · Resend</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { motion, animate, useMotionValue, useInView } from "framer-motion"
|
||||
import { ArrowRight, Building2, Wrench, CheckCircle2, Bell } from "lucide-react"
|
||||
import { useRef, useEffect } from "react"
|
||||
|
||||
// ── Animated counter ─────────────────────────────────────────────
|
||||
function Counter({ to, prefix = "", suffix = "" }: { to: number; prefix?: string; suffix?: string }) {
|
||||
const ref = useRef<HTMLSpanElement>(null)
|
||||
const inView = useInView(ref, { once: true, margin: "-80px" })
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView) return
|
||||
const ctrl = animate(0, to, {
|
||||
duration: 2,
|
||||
ease: "easeOut",
|
||||
onUpdate: (v) => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = prefix + Math.floor(v).toLocaleString() + suffix
|
||||
}
|
||||
},
|
||||
})
|
||||
return ctrl.stop
|
||||
}, [inView, to, prefix, suffix])
|
||||
|
||||
return <span ref={ref}>{prefix}0{suffix}</span>
|
||||
}
|
||||
|
||||
// ── Word-by-word text reveal ─────────────────────────────────────
|
||||
function WordReveal({ text, className = "", delay = 0 }: { text: string; className?: string; delay?: number }) {
|
||||
const words = text.split(" ")
|
||||
return (
|
||||
<span className={className}>
|
||||
{words.map((word, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 16, filter: "blur(8px)" }}
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
transition={{ duration: 0.5, delay: delay + i * 0.08, ease: "easeOut" }}
|
||||
className="inline-block mr-[0.25em]"
|
||||
>
|
||||
{word}
|
||||
</motion.span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Gradient border CTA button ───────────────────────────────────
|
||||
function GradientBorderBtn({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="gradient-border-wrapper">
|
||||
<Link href={href} className="gradient-border-btn">
|
||||
{children}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Mini dashboard mockup ────────────────────────────────────────
|
||||
const DASHBOARD_CARDS = [
|
||||
{ label: "Rent collected", value: "$12,400", sub: "This month", color: "text-emerald-400", dot: "bg-emerald-400" },
|
||||
{ label: "Open requests", value: "3", sub: "Maintenance", color: "text-amber-400", dot: "bg-amber-400" },
|
||||
{ label: "Occupancy", value: "94%", sub: "12/13 units", color: "text-indigo-400", dot: "bg-indigo-400" },
|
||||
]
|
||||
|
||||
const STATS = [
|
||||
{ prefix: "", to: 200, suffix: "+", label: "Active landlords" },
|
||||
{ prefix: "", to: 5000, suffix: "+", label: "Units managed" },
|
||||
{ prefix: "$", to: 2, suffix: "M+", label: "Rent tracked / mo" },
|
||||
{ prefix: "", to: 98, suffix: "%", label: "Collection rate" },
|
||||
]
|
||||
|
||||
function DashboardMockup() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40, rotateX: 8 }}
|
||||
animate={{ opacity: 1, y: 0, rotateX: 0 }}
|
||||
transition={{ duration: 0.9, delay: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative w-full max-w-2xl mx-auto"
|
||||
>
|
||||
<div className="absolute -inset-4 bg-gradient-to-r from-indigo-600/20 via-violet-600/20 to-indigo-600/20 rounded-3xl blur-3xl" />
|
||||
|
||||
<div className="relative rounded-2xl border border-white/[0.08] bg-[#0f0f17] overflow-hidden shadow-2xl shadow-black/60">
|
||||
{/* Browser chrome */}
|
||||
<div className="flex items-center gap-2 border-b border-white/[0.06] bg-[#0a0a12] px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500/60" />
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500/60" />
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500/60" />
|
||||
</div>
|
||||
<div className="flex-1 mx-4 rounded-md bg-white/[0.04] px-3 py-1 text-xs text-white/30 font-mono">
|
||||
app.propertymanagement.network/dashboard
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-white/40">Good morning 👋</p>
|
||||
<p className="text-sm font-semibold text-white">Dashboard Overview</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-indigo-600/20 border border-indigo-500/30 px-3 py-1.5">
|
||||
<Bell className="h-3.5 w-3.5 text-indigo-400" />
|
||||
<span className="text-xs text-indigo-300">2 alerts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{DASHBOARD_CARDS.map((c) => (
|
||||
<div key={c.label} className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${c.dot}`} />
|
||||
<span className="text-[10px] text-white/40">{c.label}</span>
|
||||
</div>
|
||||
<p className={`text-lg font-bold ${c.color}`}>{c.value}</p>
|
||||
<p className="text-[10px] text-white/30">{c.sub}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] overflow-hidden">
|
||||
<div className="px-4 py-2.5 border-b border-white/[0.04]">
|
||||
<p className="text-xs font-medium text-white/60">Recent Payments</p>
|
||||
</div>
|
||||
{[
|
||||
{ name: "Sarah J.", amount: "$1,800", status: "paid", color: "text-emerald-400 bg-emerald-500/10" },
|
||||
{ name: "Marcus L.", amount: "$1,350", status: "overdue", color: "text-red-400 bg-red-500/10" },
|
||||
{ name: "Priya P.", amount: "$2,200", status: "pending", color: "text-amber-400 bg-amber-500/10" },
|
||||
].map((row) => (
|
||||
<div key={row.name} className="flex items-center justify-between px-4 py-2.5 border-b border-white/[0.03] last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-6 w-6 rounded-full bg-indigo-600/30 flex items-center justify-center text-[9px] font-bold text-indigo-300">
|
||||
{row.name[0]}
|
||||
</div>
|
||||
<span className="text-xs text-white/70">{row.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-white">{row.amount}</span>
|
||||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${row.color}`}>{row.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating cards */}
|
||||
<motion.div
|
||||
animate={{ y: [0, -6, 0] }}
|
||||
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="absolute -right-8 top-16 hidden lg:block"
|
||||
>
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-[#0f0f17]/90 backdrop-blur p-3 shadow-xl min-w-[140px]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-xs font-semibold text-white">Rent received</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-emerald-400">$1,800</p>
|
||||
<p className="text-[10px] text-white/40">Unit 1A · Sarah J.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
animate={{ y: [0, 6, 0] }}
|
||||
transition={{ duration: 3.5, repeat: Infinity, ease: "easeInOut", delay: 0.5 }}
|
||||
className="absolute -left-8 bottom-24 hidden lg:block"
|
||||
>
|
||||
<div className="rounded-xl border border-amber-500/20 bg-[#0f0f17]/90 backdrop-blur p-3 shadow-xl min-w-[160px]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Wrench className="h-4 w-4 text-amber-400" />
|
||||
<span className="text-xs font-semibold text-white">New request</span>
|
||||
</div>
|
||||
<p className="text-xs text-white/60">Leaking faucet</p>
|
||||
<p className="text-[10px] text-white/40 mt-0.5">Unit 2B · 2 min ago</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hero ──────────────────────────────────────────────────────────
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="relative overflow-hidden pt-24 sm:pt-32 pb-12 sm:pb-16 min-h-screen flex flex-col justify-center">
|
||||
{/* Aurora background */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 h-[600px] w-[900px] rounded-full bg-indigo-600/20 blur-[120px]" />
|
||||
<div className="absolute top-20 left-1/4 h-[400px] w-[400px] rounded-full bg-violet-600/15 blur-[100px]" />
|
||||
<div className="absolute top-40 right-1/4 h-[300px] w-[300px] rounded-full bg-blue-600/10 blur-[80px]" />
|
||||
{/* Grid */}
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: "linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px)",
|
||||
backgroundSize: "60px 60px",
|
||||
}} />
|
||||
{/* Noise */}
|
||||
<div className="absolute inset-0 opacity-[0.025]" style={{
|
||||
backgroundImage: "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 w-full">
|
||||
<div className="grid lg:grid-cols-2 gap-10 lg:gap-16 items-center">
|
||||
{/* Left — copy */}
|
||||
<div>
|
||||
{/* Badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-6 inline-flex items-center gap-2 rounded-full border border-indigo-500/30 bg-indigo-500/10 px-4 py-1.5 text-xs font-medium text-indigo-300"
|
||||
>
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-indigo-400" />
|
||||
</span>
|
||||
Trusted by 200+ landlords worldwide
|
||||
</motion.div>
|
||||
|
||||
{/* Headline with word reveal */}
|
||||
<h1 className="text-4xl font-bold leading-[1.1] tracking-tight sm:text-5xl lg:text-6xl">
|
||||
<WordReveal text="Property management" delay={0.1} />
|
||||
{" "}
|
||||
<br className="hidden sm:block" />
|
||||
<span className="bg-gradient-to-r from-indigo-400 via-violet-400 to-indigo-400 bg-clip-text text-transparent bg-[length:200%] animate-gradient">
|
||||
<WordReveal text="without the chaos" delay={0.4} />
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.8 }}
|
||||
className="mt-6 text-base sm:text-lg text-white/60 leading-relaxed"
|
||||
>
|
||||
Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords.
|
||||
</motion.p>
|
||||
|
||||
{/* CTAs */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 1 }}
|
||||
className="mt-8 flex flex-col sm:flex-row flex-wrap gap-3"
|
||||
>
|
||||
{/* Gradient border primary CTA */}
|
||||
<div className="gradient-border-wrapper">
|
||||
<Link
|
||||
href="/signup"
|
||||
className="gradient-border-btn flex items-center gap-2"
|
||||
>
|
||||
Start for free <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="#features"
|
||||
className="flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-6 py-3.5 text-sm font-medium text-white/70 hover:text-white hover:bg-white/[0.08] transition-all"
|
||||
>
|
||||
See how it works
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Trust signals */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 1.2 }}
|
||||
className="mt-8 flex flex-wrap items-center gap-3 sm:gap-4 text-xs text-white/40"
|
||||
>
|
||||
{["Free plan forever", "No credit card required", "Setup in 5 minutes"].map((t) => (
|
||||
<div key={t} className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
|
||||
{t}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Animated stats */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 1.3 }}
|
||||
className="mt-10 grid grid-cols-2 sm:grid-cols-4 gap-4 pt-8 border-t border-white/[0.06]"
|
||||
>
|
||||
{STATS.map(({ prefix, to, suffix, label }) => (
|
||||
<div key={label}>
|
||||
<p className="text-2xl font-bold text-white tabular-nums">
|
||||
<Counter prefix={prefix} to={to} suffix={suffix} />
|
||||
</p>
|
||||
<p className="text-xs text-white/40 mt-0.5">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Right — dashboard mockup */}
|
||||
<div className="hidden lg:flex justify-center">
|
||||
<DashboardMockup />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { Building2, Users, TrendingUp } from "lucide-react"
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
step: "01",
|
||||
icon: Building2,
|
||||
title: "Add your properties",
|
||||
body: "Create properties, add units with rent amounts, and upload documents. Takes 3 minutes per property.",
|
||||
color: "text-indigo-400",
|
||||
bg: "bg-indigo-500/10 border-indigo-500/30",
|
||||
shadow: "shadow-indigo-500/20",
|
||||
},
|
||||
{
|
||||
step: "02",
|
||||
icon: Users,
|
||||
title: "Invite your tenants",
|
||||
body: "Add tenant profiles, assign them to units, create leases. Each tenant automatically gets a private portal link.",
|
||||
color: "text-violet-400",
|
||||
bg: "bg-violet-500/10 border-violet-500/30",
|
||||
shadow: "shadow-violet-500/20",
|
||||
},
|
||||
{
|
||||
step: "03",
|
||||
icon: TrendingUp,
|
||||
title: "Run on autopilot",
|
||||
body: "Rent reminders go out automatically. Maintenance gets logged. Lease expiries alert you 60 days early.",
|
||||
color: "text-emerald-400",
|
||||
bg: "bg-emerald-500/10 border-emerald-500/30",
|
||||
shadow: "shadow-emerald-500/20",
|
||||
},
|
||||
]
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<section id="how-it-works" className="relative mx-auto max-w-7xl px-4 sm:px-6 py-16 sm:py-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400 mb-3">How it works</p>
|
||||
<h2 className="text-3xl font-bold text-white sm:text-4xl">Up and running in under 10 minutes</h2>
|
||||
<p className="mt-4 text-white/50 max-w-xl mx-auto">
|
||||
No training, no onboarding call. Sign up, add your properties, and you're managing.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Connector line */}
|
||||
<div className="absolute top-10 left-[calc(16.66%+2rem)] right-[calc(16.66%+2rem)] hidden md:block h-px bg-gradient-to-r from-indigo-500/40 via-violet-500/40 to-emerald-500/40" />
|
||||
|
||||
{STEPS.map((step, i) => (
|
||||
<motion.div
|
||||
key={step.step}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: i * 0.15 }}
|
||||
className="flex flex-col items-center text-center"
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.08 }}
|
||||
transition={{ type: "spring", stiffness: 300 }}
|
||||
className={`relative z-10 mb-6 flex h-20 w-20 items-center justify-center rounded-2xl border shadow-2xl ${step.bg} ${step.shadow}`}
|
||||
>
|
||||
<step.icon className={`h-8 w-8 ${step.color}`} />
|
||||
<div className="absolute -top-2.5 -right-2.5 flex h-6 w-6 items-center justify-center rounded-full bg-[#09090b] border border-white/10 text-[10px] font-bold text-white/50">
|
||||
{step.step}
|
||||
</div>
|
||||
</motion.div>
|
||||
<h3 className="text-lg font-semibold text-white mb-3">{step.title}</h3>
|
||||
<p className="text-sm text-white/50 leading-relaxed max-w-xs">{step.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-14 text-center"
|
||||
>
|
||||
<a
|
||||
href="/signup"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-6 py-3 text-sm font-semibold text-white hover:bg-indigo-500 transition-all hover:shadow-lg hover:shadow-indigo-500/30 hover:-translate-y-0.5"
|
||||
>
|
||||
Start for free — takes 3 minutes
|
||||
</a>
|
||||
</motion.div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import {
|
||||
CreditCard, Mail, Cloud, Zap, BookOpen, MessageSquare,
|
||||
BarChart3, Archive, FileText, Smartphone, Lock, RefreshCw,
|
||||
} from "lucide-react"
|
||||
|
||||
const TOOLS_ROW1 = [
|
||||
{ name: "Stripe", icon: CreditCard, color: "text-violet-400", bg: "bg-violet-500/10 border-violet-500/20", desc: "Payments" },
|
||||
{ name: "Gmail", icon: Mail, color: "text-red-400", bg: "bg-red-500/10 border-red-500/20", desc: "Email" },
|
||||
{ name: "Google Drive", icon: Cloud, color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20", desc: "Storage" },
|
||||
{ name: "Zapier", icon: Zap, color: "text-orange-400", bg: "bg-orange-500/10 border-orange-500/20", desc: "Automation" },
|
||||
{ name: "Xero", icon: BarChart3, color: "text-cyan-400", bg: "bg-cyan-500/10 border-cyan-500/20", desc: "Accounting" },
|
||||
{ name: "Dropbox", icon: Archive, color: "text-blue-300", bg: "bg-blue-400/10 border-blue-400/20", desc: "Documents" },
|
||||
]
|
||||
|
||||
const TOOLS_ROW2 = [
|
||||
{ name: "Slack", icon: MessageSquare, color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20", desc: "Notifications" },
|
||||
{ name: "Notion", icon: FileText, color: "text-white/70", bg: "bg-white/5 border-white/10", desc: "Notes" },
|
||||
{ name: "QuickBooks", icon: BookOpen, color: "text-green-400", bg: "bg-green-500/10 border-green-500/20", desc: "Accounting" },
|
||||
{ name: "WhatsApp", icon: Smartphone, color: "text-emerald-300", bg: "bg-emerald-400/10 border-emerald-400/20", desc: "Reminders" },
|
||||
{ name: "2FA / Auth", icon: Lock, color: "text-indigo-400", bg: "bg-indigo-500/10 border-indigo-500/20", desc: "Security" },
|
||||
{ name: "Auto-sync", icon: RefreshCw, color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20", desc: "Sync" },
|
||||
]
|
||||
|
||||
function ToolBadge({ name, icon: Icon, color, bg, desc }: typeof TOOLS_ROW1[0]) {
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05, y: -2 }}
|
||||
transition={{ type: "spring", stiffness: 300 }}
|
||||
className={`flex shrink-0 items-center gap-3 rounded-xl border px-4 py-3 ${bg} cursor-default mx-3`}
|
||||
>
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg bg-black/20`}>
|
||||
<Icon className={`h-4 w-4 ${color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-white">{name}</p>
|
||||
<p className="text-[10px] text-white/40">{desc}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Integrations() {
|
||||
const doubled1 = [...TOOLS_ROW1, ...TOOLS_ROW1]
|
||||
const doubled2 = [...TOOLS_ROW2, ...TOOLS_ROW2]
|
||||
|
||||
return (
|
||||
<section className="py-14 sm:py-20 overflow-hidden">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 mb-10 sm:mb-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400 mb-3">Integrations</p>
|
||||
<h2 className="text-3xl font-bold text-white sm:text-4xl">Works with tools you already use</h2>
|
||||
<p className="mt-4 text-white/50 max-w-xl mx-auto">
|
||||
Property Management Network connects with your existing stack — from payments to accounting to communication.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Marquee rows */}
|
||||
<div className="space-y-4">
|
||||
{/* Row 1 — left to right */}
|
||||
<div className="relative flex overflow-hidden">
|
||||
<div className="pointer-events-none absolute left-0 top-0 z-10 h-full w-32 bg-gradient-to-r from-[#09090b] to-transparent" />
|
||||
<div className="pointer-events-none absolute right-0 top-0 z-10 h-full w-32 bg-gradient-to-l from-[#09090b] to-transparent" />
|
||||
<motion.div
|
||||
animate={{ x: ["0%", "-50%"] }}
|
||||
transition={{ duration: 22, ease: "linear", repeat: Infinity }}
|
||||
className="flex"
|
||||
>
|
||||
{doubled1.map((t, i) => <ToolBadge key={i} {...t} />)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 — right to left */}
|
||||
<div className="relative flex overflow-hidden">
|
||||
<div className="pointer-events-none absolute left-0 top-0 z-10 h-full w-32 bg-gradient-to-r from-[#09090b] to-transparent" />
|
||||
<div className="pointer-events-none absolute right-0 top-0 z-10 h-full w-32 bg-gradient-to-l from-[#09090b] to-transparent" />
|
||||
<motion.div
|
||||
animate={{ x: ["-50%", "0%"] }}
|
||||
transition={{ duration: 25, ease: "linear", repeat: Infinity }}
|
||||
className="flex"
|
||||
>
|
||||
{doubled2.map((t, i) => <ToolBadge key={i} {...t} />)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
className="mt-8 text-center text-xs text-white/30"
|
||||
>
|
||||
+ more integrations via Zapier webhooks
|
||||
</motion.p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
const ITEMS = [
|
||||
"🏠 Sarah J. paid $1,800 rent",
|
||||
"📋 New lease signed — Unit 4B",
|
||||
"🔧 Maintenance resolved — HVAC",
|
||||
"✅ 94% occupancy this month",
|
||||
"💰 $12,400 collected in April",
|
||||
"📩 Reminder sent to 3 tenants",
|
||||
"🏢 2 leases expiring this month",
|
||||
"⭐ Tenant portal accessed 24× today",
|
||||
]
|
||||
|
||||
export function Marquee() {
|
||||
const doubled = [...ITEMS, ...ITEMS]
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden border-y border-white/[0.04] bg-white/[0.02] py-4">
|
||||
{/* Fade edges */}
|
||||
<div className="pointer-events-none absolute left-0 top-0 z-10 h-full w-24 bg-gradient-to-r from-[#09090b] to-transparent" />
|
||||
<div className="pointer-events-none absolute right-0 top-0 z-10 h-full w-24 bg-gradient-to-l from-[#09090b] to-transparent" />
|
||||
|
||||
<motion.div
|
||||
animate={{ x: ["0%", "-50%"] }}
|
||||
transition={{ duration: 28, ease: "linear", repeat: Infinity }}
|
||||
className="flex gap-0 whitespace-nowrap"
|
||||
>
|
||||
{doubled.map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-8 px-8">
|
||||
<span className="text-sm text-white/40">{item}</span>
|
||||
<span className="text-white/10">·</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Building2, Menu, X, ArrowRight } from "lucide-react"
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "How it works", href: "#how-it-works" },
|
||||
{ label: "Pricing", href: "#pricing" },
|
||||
{ label: "FAQ", href: "#faq" },
|
||||
]
|
||||
|
||||
export function Navbar() {
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setScrolled(window.scrollY > 24)
|
||||
window.addEventListener("scroll", handler, { passive: true })
|
||||
return () => window.removeEventListener("scroll", handler)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.nav
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
scrolled
|
||||
? "bg-[#09090b]/80 backdrop-blur-2xl border-b border-white/[0.06] shadow-2xl shadow-black/30"
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6">
|
||||
<Link href="/" className="flex items-center gap-2 group">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg shadow-indigo-500/30 group-hover:shadow-indigo-500/50 transition-shadow">
|
||||
<Building2 className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-white tracking-tight">Property Management Network</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{NAV_LINKS.map((l) => (
|
||||
<Link
|
||||
key={l.label}
|
||||
href={l.href}
|
||||
className="px-4 py-2 text-sm text-white/60 hover:text-white hover:bg-white/[0.06] rounded-lg transition-all"
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
<Link href="/login" className="text-sm text-white/60 hover:text-white transition px-3 py-2">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/signup"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 transition-all hover:shadow-lg hover:shadow-indigo-500/25"
|
||||
>
|
||||
Get started free <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button onClick={() => setOpen(!open)} className="md:hidden p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/[0.06] transition">
|
||||
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</motion.nav>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-x-0 top-16 z-40 bg-[#09090b]/95 backdrop-blur-2xl border-b border-white/[0.06] md:hidden"
|
||||
>
|
||||
<div className="flex flex-col px-6 py-5 gap-1">
|
||||
{NAV_LINKS.map((l) => (
|
||||
<Link key={l.label} href={l.href} onClick={() => setOpen(false)}
|
||||
className="text-sm text-white/70 hover:text-white hover:bg-white/[0.04] rounded-lg px-3 py-2.5 transition">
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex flex-col gap-2 pt-3 mt-2 border-t border-white/[0.06]">
|
||||
<Link href="/login" onClick={() => setOpen(false)} className="text-sm text-white/60 px-3 py-2">Sign in</Link>
|
||||
<Link href="/signup" onClick={() => setOpen(false)}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white text-center hover:bg-indigo-500 transition">
|
||||
Get started free
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { Check, X, Zap, ShieldCheck } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
key: "starter",
|
||||
name: "Starter",
|
||||
monthlyPrice: 0,
|
||||
description: "For landlords just getting started.",
|
||||
highlight: false,
|
||||
cta: "Get started free",
|
||||
ctaHref: "/signup",
|
||||
features: ["1 property", "Up to 3 tenants", "100 MB storage", "Rent tracking", "Maintenance portal", "Lease tracking", "Tenant portal"],
|
||||
},
|
||||
{
|
||||
key: "pro",
|
||||
name: "Pro",
|
||||
monthlyPrice: 29,
|
||||
description: "For active landlords growing their portfolio.",
|
||||
highlight: true,
|
||||
badge: "Most popular",
|
||||
cta: "Start Pro",
|
||||
ctaHref: "/signup",
|
||||
features: ["10 properties", "Unlimited tenants", "5 GB storage", "50 AI calls/mo", "Email reminders", "Stripe payment links", "Lease expiry alerts", "CSV export"],
|
||||
},
|
||||
{
|
||||
key: "landlord",
|
||||
name: "Landlord",
|
||||
monthlyPrice: 59,
|
||||
description: "For serious portfolios at scale.",
|
||||
highlight: false,
|
||||
cta: "Start Landlord",
|
||||
ctaHref: "/signup",
|
||||
features: ["Unlimited properties", "Unlimited tenants", "25 GB storage", "200 AI calls/mo", "Team access", "White-label portal", "Priority support"],
|
||||
},
|
||||
{
|
||||
key: "lifetime",
|
||||
name: "Lifetime",
|
||||
monthlyPrice: null,
|
||||
fixedPrice: 199,
|
||||
description: "Pay once, own it forever.",
|
||||
highlight: false,
|
||||
badge: "Best value",
|
||||
cta: "Get lifetime access",
|
||||
ctaHref: "/signup",
|
||||
features: ["Everything in Landlord", "No recurring fees — ever", "All future updates", "White-label portal", "Team access", "25 GB storage"],
|
||||
},
|
||||
]
|
||||
|
||||
const COMPARISON = [
|
||||
{ feature: "Properties", starter: "1", pro: "10", landlord: "Unlimited", lifetime: "Unlimited" },
|
||||
{ feature: "Tenants", starter: "3", pro: "Unlimited", landlord: "Unlimited", lifetime: "Unlimited" },
|
||||
{ feature: "Storage", starter: "100 MB", pro: "5 GB", landlord: "25 GB", lifetime: "25 GB" },
|
||||
{ feature: "AI calls / month", starter: false, pro: "50", landlord: "200", lifetime: "200" },
|
||||
{ feature: "Email reminders", starter: false, pro: true, landlord: true, lifetime: true },
|
||||
{ feature: "Stripe payment links", starter: false, pro: true, landlord: true, lifetime: true },
|
||||
{ feature: "Team access", starter: false, pro: false, landlord: true, lifetime: true },
|
||||
{ feature: "White-label portal", starter: false, pro: false, landlord: true, lifetime: true },
|
||||
{ feature: "CSV export", starter: true, pro: true, landlord: true, lifetime: true },
|
||||
{ feature: "Lease expiry alerts", starter: true, pro: true, landlord: true, lifetime: true },
|
||||
]
|
||||
|
||||
function Cell({ val }: { val: string | boolean }) {
|
||||
if (val === true) return <Check className="mx-auto h-4 w-4 text-emerald-400" />
|
||||
if (val === false) return <X className="mx-auto h-4 w-4 text-white/20" />
|
||||
return <span className="text-sm font-medium text-white/80">{val}</span>
|
||||
}
|
||||
|
||||
export function PricingSection() {
|
||||
const [annual, setAnnual] = useState(false)
|
||||
const DISCOUNT = 0.8
|
||||
|
||||
return (
|
||||
<section id="pricing" className="relative py-24 overflow-hidden">
|
||||
{/* Section background */}
|
||||
<div className="absolute inset-0 -z-10 bg-gradient-to-b from-transparent via-indigo-950/20 to-transparent" />
|
||||
<div className="absolute inset-0 -z-10" style={{
|
||||
backgroundImage: "radial-gradient(ellipse 80% 50% at 50% 0%, rgba(99,102,241,0.08) 0%, transparent 70%)"
|
||||
}} />
|
||||
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="mb-12 text-center"
|
||||
>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-indigo-400">Pricing</p>
|
||||
<h2 className="text-3xl font-bold text-white sm:text-4xl">Simple, honest pricing</h2>
|
||||
<p className="mt-3 text-white/50">Start free, upgrade when you grow. No per-unit fees, ever.</p>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="mt-8 inline-flex items-center rounded-full border border-white/10 bg-white/[0.04] p-1.5">
|
||||
<button
|
||||
onClick={() => setAnnual(false)}
|
||||
className={cn(
|
||||
"rounded-full px-4 sm:px-5 py-1.5 sm:py-2 text-xs sm:text-sm font-medium transition-all duration-200",
|
||||
!annual ? "bg-indigo-600 text-white shadow-lg shadow-indigo-500/30" : "text-white/50 hover:text-white"
|
||||
)}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAnnual(true)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 sm:gap-2 rounded-full px-4 sm:px-5 py-1.5 sm:py-2 text-xs sm:text-sm font-medium transition-all duration-200",
|
||||
annual ? "bg-indigo-600 text-white shadow-lg shadow-indigo-500/30" : "text-white/50 hover:text-white"
|
||||
)}
|
||||
>
|
||||
Annual
|
||||
<span className="rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-bold text-emerald-400 border border-emerald-500/30">
|
||||
–20%
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Plan cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 items-start">
|
||||
{PLANS.map((plan, i) => {
|
||||
const isLifetime = plan.fixedPrice !== undefined
|
||||
const displayPrice = isLifetime
|
||||
? `$${plan.fixedPrice}`
|
||||
: plan.monthlyPrice === 0
|
||||
? "Free"
|
||||
: annual
|
||||
? `$${Math.round(plan.monthlyPrice! * DISCOUNT * 12)}`
|
||||
: `$${plan.monthlyPrice}`
|
||||
const interval = isLifetime
|
||||
? " one-time"
|
||||
: plan.monthlyPrice === 0
|
||||
? ""
|
||||
: annual ? "/yr" : "/mo"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={plan.key}
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-2xl p-6 transition-all duration-300",
|
||||
plan.highlight
|
||||
? "border-2 border-indigo-500/60 bg-gradient-to-b from-indigo-600/15 to-[#16161f] shadow-2xl shadow-indigo-500/20 ring-1 ring-indigo-500/20"
|
||||
: "border border-white/10 bg-[#16161f] hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
{plan.badge && (
|
||||
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 z-10">
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold shadow-lg",
|
||||
plan.highlight
|
||||
? "bg-indigo-600 text-white shadow-indigo-500/40"
|
||||
: "bg-[#1d1d28] border border-white/10 text-white/70"
|
||||
)}>
|
||||
<Zap className="h-3 w-3" />
|
||||
{plan.badge}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan name + price */}
|
||||
<div className="mb-6 pt-1">
|
||||
<p className={cn("text-xs font-semibold uppercase tracking-wide mb-2", plan.highlight ? "text-indigo-400" : "text-white/40")}>
|
||||
{plan.name}
|
||||
</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl sm:text-4xl font-extrabold text-white tracking-tight">{displayPrice}</span>
|
||||
{interval && <span className="text-sm text-white/40 ml-0.5">{interval}</span>}
|
||||
</div>
|
||||
{annual && !isLifetime && plan.monthlyPrice! > 0 && (
|
||||
<p className="mt-1 text-xs text-emerald-400 font-medium">
|
||||
Saves ${Math.round(plan.monthlyPrice! * 12 * 0.2)}/year
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/40 leading-relaxed">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
href={plan.ctaHref}
|
||||
className={cn(
|
||||
"mb-6 block rounded-xl px-4 py-2.5 text-center text-sm font-semibold transition-all",
|
||||
plan.highlight
|
||||
? "bg-indigo-600 text-white hover:bg-indigo-500 hover:shadow-lg hover:shadow-indigo-500/30"
|
||||
: "border border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
{plan.cta}
|
||||
</Link>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="flex-1 space-y-2.5">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2.5">
|
||||
<Check className={cn("mt-0.5 h-3.5 w-3.5 flex-shrink-0", plan.highlight ? "text-indigo-400" : "text-white/40")} />
|
||||
<span className="text-xs text-white/60 leading-relaxed">{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Money-back guarantee */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
className="mt-8 flex items-center justify-center gap-2 text-xs text-white/40"
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-400" />
|
||||
<span>30-day money-back guarantee on all paid plans. No questions asked.</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Comparison table */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="mt-16"
|
||||
>
|
||||
<h3 className="mb-6 text-center text-lg font-semibold text-white">Full feature comparison</h3>
|
||||
<div className="overflow-x-auto rounded-2xl border border-white/[0.08] bg-[#16161f]">
|
||||
<table className="w-full min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.08]">
|
||||
<th className="px-5 py-4 text-left text-xs font-medium text-white/40 w-[40%]">Feature</th>
|
||||
{[
|
||||
{ name: "Starter", highlight: false },
|
||||
{ name: "Pro", highlight: true },
|
||||
{ name: "Landlord", highlight: false },
|
||||
{ name: "Lifetime", highlight: false },
|
||||
].map((h) => (
|
||||
<th key={h.name} className={cn(
|
||||
"px-4 py-4 text-center text-xs font-semibold",
|
||||
h.highlight ? "text-indigo-400" : "text-white/60"
|
||||
)}>
|
||||
{h.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPARISON.map((row, i) => (
|
||||
<tr key={row.feature} className={cn(
|
||||
"border-b border-white/[0.04] last:border-0",
|
||||
i % 2 === 0 ? "bg-transparent" : "bg-white/[0.015]"
|
||||
)}>
|
||||
<td className="px-5 py-3.5 text-sm text-white/60">{row.feature}</td>
|
||||
<td className="px-4 py-3.5 text-center"><Cell val={row.starter} /></td>
|
||||
<td className="px-4 py-3.5 text-center bg-indigo-500/[0.04]"><Cell val={row.pro} /></td>
|
||||
<td className="px-4 py-3.5 text-center"><Cell val={row.landlord} /></td>
|
||||
<td className="px-4 py-3.5 text-center"><Cell val={row.lifetime} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import Link from "next/link"
|
||||
import { Check, Zap } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: "Starter",
|
||||
price: "Free",
|
||||
interval: "",
|
||||
description: "For landlords just getting started.",
|
||||
highlight: false,
|
||||
cta: "Get started free",
|
||||
ctaHref: "/signup",
|
||||
features: [
|
||||
"1 property",
|
||||
"Up to 3 tenants",
|
||||
"100 MB document storage",
|
||||
"Rent tracking",
|
||||
"Maintenance requests",
|
||||
"Lease tracking",
|
||||
"Tenant portal",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "$29",
|
||||
interval: "/mo",
|
||||
description: "For active landlords managing multiple properties.",
|
||||
highlight: true,
|
||||
badge: "Most popular",
|
||||
cta: "Start Pro",
|
||||
ctaHref: "/signup",
|
||||
features: [
|
||||
"Up to 10 properties",
|
||||
"Unlimited tenants",
|
||||
"5 GB document storage",
|
||||
"AI rent receipts (50/mo)",
|
||||
"AI maintenance reports",
|
||||
"Automated rent reminders",
|
||||
"Stripe payment links",
|
||||
"Lease expiry alerts",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Landlord",
|
||||
price: "$59",
|
||||
interval: "/mo",
|
||||
description: "For serious portfolios that need full power.",
|
||||
highlight: false,
|
||||
cta: "Start Landlord",
|
||||
ctaHref: "/signup",
|
||||
features: [
|
||||
"Unlimited properties",
|
||||
"Unlimited tenants",
|
||||
"25 GB document storage",
|
||||
"AI calls (200/mo)",
|
||||
"Team access",
|
||||
"White-label tenant portal",
|
||||
"Priority support",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Lifetime",
|
||||
price: "$199",
|
||||
interval: " one-time",
|
||||
description: "Pay once, own it forever.",
|
||||
highlight: false,
|
||||
badge: "Best value",
|
||||
cta: "Get lifetime access",
|
||||
ctaHref: "/signup",
|
||||
features: [
|
||||
"Everything in Landlord",
|
||||
"No recurring fees — ever",
|
||||
"All future updates included",
|
||||
"White-label tenant portal",
|
||||
"Team access",
|
||||
"25 GB document storage",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function Pricing() {
|
||||
return (
|
||||
<section id="pricing" className="py-24">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<div className="mb-14 text-center">
|
||||
<p className="mb-3 text-sm font-semibold uppercase tracking-widest text-indigo-400">Pricing</p>
|
||||
<h2 className="text-3xl font-bold text-white sm:text-4xl">Simple, honest pricing</h2>
|
||||
<p className="mx-auto mt-4 max-w-lg text-white/50">
|
||||
Start free, upgrade when you grow. No hidden fees, no per-unit charges.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.name}
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-2xl border p-6",
|
||||
plan.highlight
|
||||
? "border-indigo-500/50 bg-indigo-600/10 shadow-xl shadow-indigo-600/10"
|
||||
: "border-white/[0.06] bg-[#16161f]"
|
||||
)}
|
||||
>
|
||||
{plan.badge && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold",
|
||||
plan.highlight
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-white/10 text-white/70"
|
||||
)}>
|
||||
<Zap className="h-3 w-3" />
|
||||
{plan.badge}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5">
|
||||
<p className="text-sm font-semibold text-white/60">{plan.name}</p>
|
||||
<div className="mt-1 flex items-baseline gap-0.5">
|
||||
<span className="text-3xl font-extrabold text-white">{plan.price}</span>
|
||||
{plan.interval && <span className="text-sm text-white/40">{plan.interval}</span>}
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-relaxed text-white/40">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={plan.ctaHref}
|
||||
className={cn(
|
||||
"mb-6 block rounded-lg px-4 py-2.5 text-center text-sm font-semibold transition",
|
||||
plan.highlight
|
||||
? "bg-indigo-600 text-white hover:bg-indigo-500"
|
||||
: "border border-white/10 text-white/70 hover:border-white/20 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{plan.cta}
|
||||
</Link>
|
||||
|
||||
<ul className="flex-1 space-y-2.5">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2">
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-indigo-400" />
|
||||
<span className="text-xs text-white/60">{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { useInView } from "framer-motion"
|
||||
import { useRef } from "react"
|
||||
import { X, Check } from "lucide-react"
|
||||
|
||||
const BEFORE = [
|
||||
"Chasing rent via text message every month",
|
||||
"Spreadsheets breaking when you add a new property",
|
||||
"Tenants calling you at 11pm about maintenance",
|
||||
"Losing track of lease expiry dates",
|
||||
"No idea which property is actually profitable",
|
||||
"Receipts and invoices scattered in email",
|
||||
]
|
||||
|
||||
const AFTER = [
|
||||
"Automated reminders — tenants pay on time",
|
||||
"One dashboard for all properties, all tenants",
|
||||
"Tenants submit requests through their portal",
|
||||
"60-day lease expiry alerts, auto-renewal ready",
|
||||
"Per-property expense tracking and P&L view",
|
||||
"Expenses categorised and exportable as CSV",
|
||||
]
|
||||
|
||||
function fadeIn(delay = 0) {
|
||||
return {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
whileInView: { opacity: 1, y: 0 },
|
||||
viewport: { once: true },
|
||||
transition: { duration: 0.5, delay },
|
||||
}
|
||||
}
|
||||
|
||||
export function Problem() {
|
||||
return (
|
||||
<section className="mx-auto max-w-7xl px-4 sm:px-6 py-16 sm:py-24">
|
||||
<motion.div {...fadeIn()} className="text-center mb-14">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400 mb-3">Sound familiar?</p>
|
||||
<h2 className="text-3xl font-bold text-white sm:text-4xl">The old way is exhausting</h2>
|
||||
<p className="mt-4 text-white/50 max-w-xl mx-auto">
|
||||
Most landlords are drowning in WhatsApp messages, broken spreadsheets, and missed follow-ups. There's a better way.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Before */}
|
||||
<motion.div {...fadeIn(0.1)} className="rounded-2xl border border-red-500/20 bg-red-500/[0.04] p-7">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-red-500/20">
|
||||
<X className="h-4 w-4 text-red-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-red-400">Before Property Management Network</h3>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
{BEFORE.map((item, i) => (
|
||||
<motion.li
|
||||
key={item}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.15 + i * 0.07 }}
|
||||
className="flex items-start gap-3 text-sm text-white/60"
|
||||
>
|
||||
<X className="h-4 w-4 text-red-500/60 mt-0.5 shrink-0" />
|
||||
{item}
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
{/* After */}
|
||||
<motion.div {...fadeIn(0.2)} className="rounded-2xl border border-emerald-500/20 bg-emerald-500/[0.04] p-7">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-emerald-500/20">
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-emerald-400">With Property Management Network</h3>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
{AFTER.map((item, i) => (
|
||||
<motion.li
|
||||
key={item}
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.15 + i * 0.07 }}
|
||||
className="flex items-start gap-3 text-sm text-white/60"
|
||||
>
|
||||
<Check className="h-4 w-4 text-emerald-400 mt-0.5 shrink-0" />
|
||||
{item}
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useScroll, motion } from "framer-motion"
|
||||
|
||||
export function ScrollProgress() {
|
||||
const { scrollYProgress } = useScroll()
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 right-0 z-[200] h-[2px] origin-left"
|
||||
style={{
|
||||
scaleX: scrollYProgress,
|
||||
background: "linear-gradient(90deg, #6366f1, #8b5cf6, #06b6d4)",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Home } from "lucide-react"
|
||||
|
||||
const NOTIFICATIONS = [
|
||||
{ name: "James T.", location: "Austin, TX", action: "just upgraded to Pro", time: "2 min ago" },
|
||||
{ name: "Sarah M.", location: "London, UK", action: "just signed up", time: "4 min ago" },
|
||||
{ name: "David K.", location: "Denver, CO", action: "got Lifetime access", time: "7 min ago" },
|
||||
{ name: "Priya R.", location: "Toronto, CA", action: "just signed up", time: "12 min ago" },
|
||||
{ name: "Marcus L.", location: "Miami, FL", action: "upgraded to Pro", time: "15 min ago" },
|
||||
{ name: "Rachel W.", location: "Seattle, WA", action: "just signed up", time: "18 min ago" },
|
||||
{ name: "Tom B.", location: "Chicago, IL", action: "got Lifetime access", time: "23 min ago" },
|
||||
{ name: "Aisha N.", location: "New York, NY", action: "just signed up", time: "27 min ago" },
|
||||
]
|
||||
|
||||
export function SocialProofToast() {
|
||||
const [index, setIndex] = useState(0)
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// First show after 6s
|
||||
const initialTimer = setTimeout(() => tick(0), 6000)
|
||||
return () => clearTimeout(initialTimer)
|
||||
}, [])
|
||||
|
||||
function tick(i: number) {
|
||||
setIndex(i)
|
||||
setVisible(true)
|
||||
|
||||
setTimeout(() => {
|
||||
setVisible(false)
|
||||
const next = (i + 1) % NOTIFICATIONS.length
|
||||
setTimeout(() => tick(next), 4000) // gap between toasts
|
||||
}, 5000) // show duration
|
||||
}
|
||||
|
||||
const notif = NOTIFICATIONS[index]
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-24 left-6 z-[80] md:bottom-8">
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -40, scale: 0.95 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: -20, scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
className="flex items-center gap-3 rounded-2xl border border-white/10 bg-[#16161f]/95 backdrop-blur-xl px-4 py-3 shadow-2xl shadow-black/40 max-w-[260px]"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600/20 border border-indigo-500/30">
|
||||
<Home className="h-4 w-4 text-indigo-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-white truncate">
|
||||
{notif.name} <span className="text-white/40 font-normal">from {notif.location}</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-indigo-300">{notif.action}</p>
|
||||
<p className="text-[10px] text-white/30 mt-0.5">{notif.time}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { Star } from "lucide-react"
|
||||
|
||||
const TESTIMONIALS = [
|
||||
{
|
||||
name: "James T.",
|
||||
role: "Landlord · 6 properties · Austin, TX",
|
||||
body: "Property Management Network replaced three separate spreadsheets. My rent collection rate went from 80% to 98% in the first month because tenants actually get reminders now.",
|
||||
rating: 5,
|
||||
initials: "JT",
|
||||
color: "from-indigo-500 to-violet-600",
|
||||
},
|
||||
{
|
||||
name: "Maria S.",
|
||||
role: "Property Manager · 22 units · Miami, FL",
|
||||
body: "The maintenance portal alone is worth it. Tenants submit requests, I get notified, everything is tracked. No more texts at midnight. Absolute game changer.",
|
||||
rating: 5,
|
||||
initials: "MS",
|
||||
color: "from-emerald-500 to-teal-600",
|
||||
},
|
||||
{
|
||||
name: "David K.",
|
||||
role: "Real estate investor · 4 properties · Denver, CO",
|
||||
body: "I do my own books. The expense tracker and CSV export save me 3 hours every time I prepare for my accountant. Clean, fast, actually useful — rare.",
|
||||
rating: 5,
|
||||
initials: "DK",
|
||||
color: "from-amber-500 to-orange-600",
|
||||
},
|
||||
{
|
||||
name: "Rachel M.",
|
||||
role: "Landlord · 2 properties · Seattle, WA",
|
||||
body: "The tenant portal is brilliant. My tenants love having their own link. And lease expiry alerts saved me from an accidental month-to-month situation.",
|
||||
rating: 5,
|
||||
initials: "RM",
|
||||
color: "from-rose-500 to-pink-600",
|
||||
},
|
||||
{
|
||||
name: "Tom B.",
|
||||
role: "Portfolio investor · 11 properties · Chicago, IL",
|
||||
body: "Switched from a £150/month enterprise tool. Property Management Network does everything I actually use — at a fraction of the cost. The Lifetime deal was a no-brainer.",
|
||||
rating: 5,
|
||||
initials: "TB",
|
||||
color: "from-blue-500 to-cyan-600",
|
||||
},
|
||||
{
|
||||
name: "Aisha R.",
|
||||
role: "Property manager · 8 units · London, UK",
|
||||
body: "Gorgeous UI, fast, and actually works. I've tried 4 property management tools — this is the first one I've stuck with past 2 weeks.",
|
||||
rating: 5,
|
||||
initials: "AR",
|
||||
color: "from-violet-500 to-purple-600",
|
||||
},
|
||||
]
|
||||
|
||||
function Stars({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Star key={i} className="h-4 w-4 fill-amber-400 text-amber-400" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Testimonials() {
|
||||
return (
|
||||
<section className="mx-auto max-w-7xl px-4 sm:px-6 py-16 sm:py-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-14"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400 mb-3">Social proof</p>
|
||||
<h2 className="text-3xl font-bold text-white sm:text-4xl">Landlords love Property Management Network</h2>
|
||||
<p className="mt-4 text-white/50 max-w-xl mx-auto">
|
||||
Real feedback from property owners who ditched their spreadsheets.
|
||||
</p>
|
||||
{/* Aggregate rating */}
|
||||
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-amber-500/20 bg-amber-500/10 px-4 py-1.5">
|
||||
<Stars count={5} />
|
||||
<span className="text-sm font-semibold text-white">5.0</span>
|
||||
<span className="text-xs text-white/40">from 200+ reviews</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{TESTIMONIALS.map((t, i) => (
|
||||
<motion.div
|
||||
key={t.name}
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: i * 0.08 }}
|
||||
whileHover={{ y: -4, scale: 1.01 }}
|
||||
className="relative rounded-2xl border border-white/[0.06] bg-[#16161f] p-6 flex flex-col gap-4 group overflow-hidden cursor-default"
|
||||
>
|
||||
{/* Hover glow */}
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 bg-gradient-to-br from-white/[0.02] to-transparent" />
|
||||
|
||||
<Stars count={t.rating} />
|
||||
<p className="text-sm text-white/70 leading-relaxed flex-1 relative">"{t.body}"</p>
|
||||
<div className="flex items-center gap-3 relative">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br ${t.color} text-xs font-bold text-white shadow-lg`}>
|
||||
{t.initials}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{t.name}</p>
|
||||
<p className="text-xs text-white/40">{t.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Copy, Check } from "lucide-react"
|
||||
|
||||
export function CopyButton({ text, className }: { text: string; className?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function handleCopy() {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
title="Copy link"
|
||||
className={className ?? "flex h-7 w-7 items-center justify-center rounded-md text-white/40 transition hover:bg-white/10 hover:text-white"}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { ConfirmModal } from "@/components/ui/confirm-modal"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface DeleteButtonProps {
|
||||
id: string
|
||||
endpoint: string
|
||||
label?: string
|
||||
onDeleted?: () => void
|
||||
}
|
||||
|
||||
export function DeleteButton({ id, endpoint, label = "this item", onDeleted }: DeleteButtonProps) {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleDelete() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${endpoint}/${id}`, { method: "DELETE" })
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
toast.error(data?.error ?? "Failed to delete")
|
||||
return
|
||||
}
|
||||
toast.success("Deleted successfully")
|
||||
if (onDeleted) onDeleted()
|
||||
else router.refresh()
|
||||
} catch {
|
||||
toast.error("Network error — please try again")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-white/20 hover:text-red-400 transition"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title="Delete item?"
|
||||
description={`Are you sure you want to delete ${label}? This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
loading={loading}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Link from "next/link"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
action?: { label: string; href: string }
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center py-20 text-center", className)}>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-2xl bg-indigo-500/10 blur-xl" />
|
||||
<div className="relative flex h-16 w-16 items-center justify-center rounded-2xl border border-white/[0.08] bg-gradient-to-br from-white/[0.07] to-white/[0.02]">
|
||||
<Icon className="h-7 w-7 text-white/40" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mt-5 text-base font-semibold text-white">{title}</h3>
|
||||
<p className="mt-2 max-w-xs text-sm leading-relaxed text-white/40">{description}</p>
|
||||
{action && (
|
||||
<Link
|
||||
href={action.href}
|
||||
className="mt-6 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 hover:shadow-lg hover:shadow-indigo-500/25"
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { Upload, X, File, Loader2 } from "lucide-react"
|
||||
|
||||
interface FileUploadProps {
|
||||
propertyId: string
|
||||
onUploaded: (doc: { id: string; name: string; file_url: string; file_type: string; file_size: number }) => void
|
||||
}
|
||||
|
||||
export function FileUpload({ propertyId, onUploaded }: FileUploadProps) {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function upload(file: File) {
|
||||
setUploading(true)
|
||||
setError("")
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append("file", file)
|
||||
fd.append("property_id", propertyId)
|
||||
fd.append("name", file.name.replace(/\.[^/.]+$/, ""))
|
||||
fd.append("category", "general")
|
||||
|
||||
const res = await fetch("/api/documents", { method: "POST", body: fd })
|
||||
const data = await res.json()
|
||||
setUploading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Upload failed")
|
||||
return
|
||||
}
|
||||
|
||||
onUploaded(data)
|
||||
}
|
||||
|
||||
function handleFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return
|
||||
upload(files[0])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setDragging(false); handleFiles(e.dataTransfer.files) }}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-10 transition ${
|
||||
dragging ? "border-indigo-500 bg-indigo-500/5" : "border-white/10 bg-white/[0.02] hover:border-white/20"
|
||||
}`}
|
||||
>
|
||||
<input ref={inputRef} type="file" className="hidden" onChange={(e) => handleFiles(e.target.files)} />
|
||||
{uploading ? (
|
||||
<Loader2 className="h-7 w-7 animate-spin text-indigo-400" />
|
||||
) : (
|
||||
<Upload className="h-7 w-7 text-white/30" />
|
||||
)}
|
||||
<p className="text-sm text-white/50">
|
||||
{uploading ? "Uploading..." : "Drop a file here or click to browse"}
|
||||
</p>
|
||||
<p className="text-xs text-white/30">PDF, DOC, JPG, PNG — max 20 MB</p>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Link from "next/link"
|
||||
import { Building2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type Size = "sm" | "md" | "lg"
|
||||
|
||||
const SIZES: Record<Size, { box: string; icon: string; title: string; sub: string; gap: string }> = {
|
||||
sm: { box: "h-7 w-7", icon: "h-4 w-4", title: "text-xs", sub: "text-[9px]", gap: "gap-2" },
|
||||
md: { box: "h-8 w-8", icon: "h-[18px] w-[18px]", title: "text-[13px]", sub: "text-[10px]", gap: "gap-2.5" },
|
||||
lg: { box: "h-9 w-9", icon: "h-5 w-5", title: "text-[15px]", sub: "text-[11px]", gap: "gap-2.5" },
|
||||
}
|
||||
|
||||
/** The brand mark — gradient rounded square with the building glyph. */
|
||||
export function LogoMark({ size = "md", className }: { size?: Size; className?: string }) {
|
||||
const s = SIZES[size]
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/25",
|
||||
s.box,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Building2 className={s.icon} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Full brand lockup: mark + "Property Management" / "Network" wordmark.
|
||||
* Designed for the app's dark surfaces (white title, indigo accent).
|
||||
* Pass `href={null}` to render a non-link version.
|
||||
*/
|
||||
export function Logo({
|
||||
size = "md",
|
||||
className,
|
||||
href = "/",
|
||||
}: {
|
||||
size?: Size
|
||||
className?: string
|
||||
href?: string | null
|
||||
}) {
|
||||
const s = SIZES[size]
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<LogoMark size={size} />
|
||||
<span className="flex flex-col leading-none">
|
||||
<span className={cn("font-bold tracking-tight text-white", s.title)}>Property Management</span>
|
||||
<span className={cn("font-semibold uppercase tracking-[0.18em] text-indigo-400", s.sub)}>Network</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
const classes = cn("flex items-center", s.gap, className)
|
||||
|
||||
return href ? (
|
||||
<Link href={href} className={classes}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={classes}>{content}</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Bell, Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function SendReminderButton({ tenantId, paymentId }: { tenantId: string; paymentId: string }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function send() {
|
||||
setLoading(true)
|
||||
const res = await fetch("/api/notifications", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "rent_reminder", tenant_id: tenantId, payment_id: paymentId }),
|
||||
})
|
||||
setLoading(false)
|
||||
if (res.ok) toast.success("Reminder sent")
|
||||
else toast.error("Failed to send reminder")
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={loading}
|
||||
title="Send rent reminder"
|
||||
className="flex h-7 items-center gap-1.5 rounded-md border border-white/10 px-2.5 text-xs text-white/50 transition hover:border-amber-500/30 hover:text-amber-400 disabled:opacity-40"
|
||||
>
|
||||
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Bell className="h-3 w-3" />}
|
||||
Remind
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Skeleton({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<div className={cn("animate-pulse rounded-lg bg-white/[0.05]", className)} style={style} />
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Greeting */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-3.5 w-60" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-44 rounded-xl" />
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-9 w-9 rounded-xl" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-1.5 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Revenue + Quick Actions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-5">
|
||||
<div className="lg:col-span-2 rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-7 w-28" />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 h-24">
|
||||
{[40, 65, 80, 55, 90, 70].map((h, i) => (
|
||||
<Skeleton key={i} className="flex-1 rounded-t-lg" style={{ height: `${h}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 space-y-2">
|
||||
<Skeleton className="h-3 w-28 mb-3" />
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two col layout */}
|
||||
<div className="grid gap-4 sm:gap-5 lg:grid-cols-2">
|
||||
{[0, 1].map((i) => (
|
||||
<div key={i} className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4 flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3.5 w-14" />
|
||||
</div>
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
{Array.from({ length: 4 }).map((_, j) => (
|
||||
<div key={j} className="flex items-center justify-between px-5 py-3.5 gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 5, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-3.5 w-36" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-28 rounded-lg" />
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||
<div className="border-b border-white/[0.06] px-5 py-3 flex gap-6">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-20" />
|
||||
))}
|
||||
</div>
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-6 px-5 py-4">
|
||||
{Array.from({ length: cols }).map((_, j) => (
|
||||
<Skeleton key={j} className="h-4 w-24" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardGridSkeleton({ cards = 6 }: { cards?: number }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
<Skeleton className="h-3.5 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32 rounded-lg" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: cards }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-5 w-12 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-3.5 w-48" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { X, Zap } from "lucide-react"
|
||||
import { CheckoutButton } from "@/components/forms/checkout-button"
|
||||
|
||||
interface UpgradeModalProps {
|
||||
trigger: React.ReactNode
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export function UpgradeModal({ trigger, reason }: UpgradeModalProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={() => setOpen(true)}>{trigger}</div>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setOpen(false)} />
|
||||
<div className="relative w-full max-w-md rounded-2xl border border-white/10 bg-[#16161f] p-6 shadow-2xl">
|
||||
<button onClick={() => setOpen(false)} className="absolute right-4 top-4 text-white/30 hover:text-white transition">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-indigo-600/20">
|
||||
<Zap className="h-6 w-6 text-indigo-400" />
|
||||
</div>
|
||||
|
||||
<h2 className="mt-4 text-lg font-bold text-white">Upgrade your plan</h2>
|
||||
<p className="mt-2 text-sm text-white/50">
|
||||
{reason ?? "You've reached the limit of your current plan. Upgrade to continue."}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="rounded-lg border border-indigo-500/20 bg-indigo-600/5 p-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="font-semibold text-white">Pro</span>
|
||||
<span className="text-white/60">$29<span className="text-xs">/mo</span></span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-white/40">10 properties · Unlimited tenants · AI features</p>
|
||||
<div className="mt-3">
|
||||
<CheckoutButton plan="pro" label="Upgrade to Pro" highlight />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/[0.06] p-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="font-semibold text-white">Lifetime</span>
|
||||
<span className="text-white/60">$199<span className="text-xs"> once</span></span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-white/40">Unlimited everything · Forever</p>
|
||||
<div className="mt-3">
|
||||
<CheckoutButton plan="lifetime" label="Get Lifetime Deal" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
interface AnimatedNumberProps {
|
||||
value: number
|
||||
duration?: number
|
||||
format?: "number" | "currency" | "percent"
|
||||
className?: string
|
||||
}
|
||||
|
||||
function formatValue(n: number, format: string) {
|
||||
if (format === "currency") return "$" + n.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
||||
if (format === "percent") return n + "%"
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
export function AnimatedNumber({
|
||||
value,
|
||||
duration = 900,
|
||||
format = "number",
|
||||
className,
|
||||
}: AnimatedNumberProps) {
|
||||
const [display, setDisplay] = useState(0)
|
||||
const startRef = useRef<number | null>(null)
|
||||
const frameRef = useRef<number>(0)
|
||||
const prevRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const from = prevRef.current
|
||||
const to = value
|
||||
prevRef.current = value
|
||||
|
||||
if (from === to) {
|
||||
setDisplay(to)
|
||||
return
|
||||
}
|
||||
|
||||
startRef.current = null
|
||||
|
||||
function tick(ts: number) {
|
||||
if (!startRef.current) startRef.current = ts
|
||||
const elapsed = ts - startRef.current
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const eased = 1 - Math.pow(1 - progress, 3)
|
||||
setDisplay(Math.round(from + (to - from) * eased))
|
||||
if (progress < 1) {
|
||||
frameRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
|
||||
frameRef.current = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frameRef.current)
|
||||
}, [value, duration])
|
||||
|
||||
return <span className={className}>{formatValue(display, format)}</span>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
interface BackButtonProps {
|
||||
href?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function BackButton({ href, label = "Back" }: BackButtonProps) {
|
||||
const router = useRouter()
|
||||
|
||||
function handleClick() {
|
||||
if (href) router.push(href)
|
||||
else router.back()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="mb-4 flex items-center gap-1.5 text-sm text-white/40 transition hover:text-white/80"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { AlertTriangle, X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ConfirmModalProps {
|
||||
open: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: "danger" | "warning"
|
||||
loading?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
open,
|
||||
title = "Are you sure?",
|
||||
description = "This action cannot be undone.",
|
||||
confirmLabel = "Delete",
|
||||
cancelLabel = "Cancel",
|
||||
variant = "danger",
|
||||
loading = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setTimeout(() => confirmRef.current?.focus(), 50)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && open) onCancel()
|
||||
}
|
||||
document.addEventListener("keydown", onKey)
|
||||
return () => document.removeEventListener("keydown", onKey)
|
||||
}, [open, onCancel])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const isDanger = variant === "danger"
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center px-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-sm overflow-hidden rounded-2xl border border-white/[0.08] bg-[#16161f] shadow-2xl shadow-black/80 animate-in fade-in zoom-in-95 duration-150">
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="absolute right-4 top-4 rounded-lg p-1 text-white/30 transition hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Icon */}
|
||||
<div className={cn(
|
||||
"mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border",
|
||||
isDanger
|
||||
? "border-red-500/20 bg-red-500/10 text-red-400"
|
||||
: "border-amber-500/20 bg-amber-500/10 text-amber-400"
|
||||
)}>
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-center text-base font-bold text-white">{title}</h2>
|
||||
<p className="mt-2 text-center text-sm text-white/50">{description}</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-xl border border-white/10 py-2.5 text-sm font-medium text-white/50 transition hover:border-white/20 hover:text-white disabled:opacity-50"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"flex-1 rounded-xl py-2.5 text-sm font-semibold text-white transition disabled:opacity-50",
|
||||
isDanger
|
||||
? "bg-red-600 hover:bg-red-500 hover:shadow-lg hover:shadow-red-500/20"
|
||||
: "bg-amber-600 hover:bg-amber-500 hover:shadow-lg hover:shadow-amber-500/20"
|
||||
)}
|
||||
>
|
||||
{loading ? "Deleting…" : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Wrap a table row with this to flash it when ?highlight=<id> matches.
|
||||
* Usage: <HighlightRow id={item.id} className="...your tr classes...">
|
||||
*/
|
||||
export function HighlightRow({
|
||||
id,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
id: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
const params = useSearchParams()
|
||||
const highlight = params.get("highlight")
|
||||
const isHighlighted = highlight === id
|
||||
const ref = useRef<HTMLTableRowElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isHighlighted && ref.current) {
|
||||
ref.current.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
}
|
||||
}, [isHighlighted])
|
||||
|
||||
return (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
className,
|
||||
isHighlighted && "animate-highlight"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ArrowUp } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function ScrollToTop() {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Watch the main scroll container
|
||||
const el = document.getElementById("main-scroll")
|
||||
if (!el) return
|
||||
function onScroll() { setVisible(el!.scrollTop > 300) }
|
||||
el.addEventListener("scroll", onScroll, { passive: true })
|
||||
return () => el!.removeEventListener("scroll", onScroll)
|
||||
}, [])
|
||||
|
||||
function scrollUp() {
|
||||
document.getElementById("main-scroll")?.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={scrollUp}
|
||||
aria-label="Scroll to top"
|
||||
className={cn(
|
||||
"fixed bottom-6 right-6 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-white/[0.1] bg-[#1d1d2a] shadow-lg shadow-black/40 text-white/50 transition-all duration-300 hover:border-indigo-500/40 hover:bg-indigo-600/20 hover:text-white",
|
||||
visible ? "opacity-100 translate-y-0 pointer-events-auto" : "opacity-0 translate-y-4 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { ChevronDown, Check } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
name?: string
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onChange?: (value: string) => void
|
||||
options: SelectOption[]
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Select({
|
||||
name,
|
||||
value,
|
||||
defaultValue = "",
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "Select…",
|
||||
required,
|
||||
className,
|
||||
}: SelectProps) {
|
||||
const isControlled = value !== undefined
|
||||
const [internal, setInternal] = useState(defaultValue)
|
||||
const current = isControlled ? value : internal
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [cursor, setCursor] = useState(-1)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close on outside click
|
||||
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)
|
||||
}, [])
|
||||
|
||||
// Reset cursor when opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const idx = options.findIndex((o) => o.value === current)
|
||||
setCursor(idx >= 0 ? idx : 0)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Scroll cursor into view
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const el = listRef.current?.querySelector(`[data-idx="${cursor}"]`) as HTMLElement
|
||||
el?.scrollIntoView({ block: "nearest" })
|
||||
}, [cursor, open])
|
||||
|
||||
function pick(val: string) {
|
||||
if (!isControlled) setInternal(val)
|
||||
onChange?.(val)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent) {
|
||||
if (!open) {
|
||||
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault()
|
||||
setCursor((v) => Math.min(v + 1, options.length - 1))
|
||||
break
|
||||
case "ArrowUp":
|
||||
e.preventDefault()
|
||||
setCursor((v) => Math.max(v - 1, 0))
|
||||
break
|
||||
case "Enter":
|
||||
e.preventDefault()
|
||||
if (cursor >= 0 && options[cursor]) pick(options[cursor].value)
|
||||
break
|
||||
case "Escape":
|
||||
e.preventDefault()
|
||||
setOpen(false)
|
||||
break
|
||||
default: {
|
||||
// Jump to first option starting with typed letter
|
||||
const char = e.key.toLowerCase()
|
||||
if (char.length === 1) {
|
||||
const idx = options.findIndex((o) => o.label.toLowerCase().startsWith(char))
|
||||
if (idx >= 0) setCursor(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selected = options.find((o) => o.value === current)
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
{/* Hidden input for FormData / native form submission */}
|
||||
{name && (
|
||||
<input type="hidden" name={name} value={current} required={required} readOnly />
|
||||
)}
|
||||
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-left transition-colors",
|
||||
"hover:border-white/20 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500",
|
||||
open && "border-indigo-500/40"
|
||||
)}
|
||||
>
|
||||
<span className={selected ? "text-white" : "text-white/30"}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 shrink-0 text-white/30 transition-transform duration-150", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
className="absolute left-0 right-0 z-[100] mt-1.5 overflow-hidden rounded-xl border border-white/[0.08] bg-[#1d1d2a] shadow-2xl shadow-black/60"
|
||||
>
|
||||
<div ref={listRef} className="max-h-56 overflow-y-auto py-1.5">
|
||||
{options.map((opt, idx) => {
|
||||
const active = current === opt.value
|
||||
const highlighted = cursor === idx
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
data-idx={idx}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => pick(opt.value)}
|
||||
onMouseEnter={() => setCursor(idx)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-4 py-2.5 text-sm text-left transition-colors",
|
||||
highlighted && !active && "bg-white/[0.05] text-white",
|
||||
active
|
||||
? "bg-indigo-500/15 text-indigo-300"
|
||||
: "text-white/75"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
{active && <Check className="h-3.5 w-3.5 shrink-0 text-indigo-400" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user