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

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

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

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