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>
308 lines
13 KiB
TypeScript
308 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useRef, useEffect } from "react"
|
|
import { Send, Bot, Sparkles, Lock, Loader2, RotateCcw, Copy, Check, Zap } from "lucide-react"
|
|
import Link from "next/link"
|
|
import type { Plan } from "@/types"
|
|
|
|
interface Message {
|
|
role: "user" | "assistant"
|
|
content: string
|
|
error?: boolean
|
|
}
|
|
|
|
const SUGGESTED = [
|
|
"Which tenants have overdue rent this month?",
|
|
"Summarise my open maintenance requests",
|
|
"How is my occupancy rate?",
|
|
"Which leases are expiring in 60 days?",
|
|
"What were my total expenses this quarter?",
|
|
"Which property earns the most rent?",
|
|
]
|
|
|
|
function CopyButton({ text }: { text: string }) {
|
|
const [copied, setCopied] = useState(false)
|
|
return (
|
|
<button
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(text).catch(() => {})
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}}
|
|
className="flex items-center gap-1 rounded-lg px-2 py-1 text-[10px] text-white/25 hover:text-white/60 hover:bg-white/[0.04] transition"
|
|
title="Copy"
|
|
>
|
|
{copied ? <><Check className="h-3 w-3" /> Copied</> : <><Copy className="h-3 w-3" /> Copy</>}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function MessageBubble({ msg }: { msg: Message }) {
|
|
if (msg.role === "user") {
|
|
return (
|
|
<div className="flex justify-end">
|
|
<div className="max-w-[80%] rounded-2xl rounded-tr-sm bg-indigo-600 px-4 py-3 shadow-lg shadow-indigo-500/10">
|
|
<p className="text-sm text-white leading-relaxed">{msg.content}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex gap-3 items-start">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-md shadow-indigo-500/20 mt-0.5">
|
|
<Bot className="h-4 w-4 text-white" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className={`rounded-2xl rounded-tl-sm px-4 py-3 ${
|
|
msg.error
|
|
? "bg-red-500/8 border border-red-500/20 text-red-300"
|
|
: "bg-[#1d1d2a] border border-white/[0.06] text-white/85"
|
|
}`}>
|
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">{msg.content}</p>
|
|
</div>
|
|
{!msg.error && (
|
|
<div className="mt-1 pl-1">
|
|
<CopyButton text={msg.content} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface AiChatProps {
|
|
plan: Plan
|
|
limit: number
|
|
used: number
|
|
}
|
|
|
|
export function AiChat({ plan, limit, used }: AiChatProps) {
|
|
const [messages, setMessages] = useState<Message[]>([])
|
|
const [input, setInput] = useState("")
|
|
const [loading, setLoading] = useState(false)
|
|
const [currentUsed, setCurrentUsed] = useState(used)
|
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
const isLocked = limit === 0
|
|
const isExhausted = !isLocked && currentUsed >= limit
|
|
const usagePct = limit > 0 ? Math.min((currentUsed / limit) * 100, 100) : 0
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
}, [messages, loading])
|
|
|
|
async function send(question: string) {
|
|
if (!question.trim() || loading || isLocked || isExhausted) return
|
|
|
|
setMessages((prev) => [...prev, { role: "user", content: question }])
|
|
setInput("")
|
|
setLoading(true)
|
|
|
|
// Reset textarea height
|
|
if (inputRef.current) {
|
|
inputRef.current.style.height = "auto"
|
|
}
|
|
|
|
try {
|
|
const res = await fetch("/api/ai/ask", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ question }),
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setMessages((prev) => [...prev, { role: "assistant", content: data.error ?? "Something went wrong.", error: true }])
|
|
} else {
|
|
setMessages((prev) => [...prev, { role: "assistant", content: data.answer }])
|
|
if (data.usage) setCurrentUsed(data.usage.used)
|
|
}
|
|
} catch {
|
|
setMessages((prev) => [...prev, { role: "assistant", content: "Network error. Please try again.", error: true }])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
function handleKey(e: React.KeyboardEvent) {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault()
|
|
send(input)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-[calc(100vh-64px-2rem)] max-w-3xl mx-auto">
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-5 shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg shadow-indigo-500/20">
|
|
<Sparkles className="h-5 w-5 text-white" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-base font-bold text-white">AI Assistant</h2>
|
|
<p className="text-xs text-white/35">Powered by your live portfolio data</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
{!isLocked && (
|
|
<div className="hidden sm:flex flex-col items-end gap-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<Zap className="h-3 w-3 text-white/30" />
|
|
<span className={`text-xs font-medium tabular-nums ${isExhausted ? "text-red-400" : "text-white/50"}`}>
|
|
{currentUsed} / {limit}
|
|
</span>
|
|
</div>
|
|
<div className="w-20 h-1 rounded-full bg-white/[0.06]">
|
|
<div
|
|
className={`h-1 rounded-full transition-all ${isExhausted ? "bg-red-500" : usagePct > 75 ? "bg-amber-500" : "bg-indigo-500"}`}
|
|
style={{ width: `${usagePct}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{messages.length > 0 && (
|
|
<button
|
|
onClick={() => setMessages([])}
|
|
className="flex items-center gap-1.5 rounded-xl border border-white/[0.06] px-3 py-1.5 text-xs text-white/35 hover:text-white hover:border-white/20 transition"
|
|
>
|
|
<RotateCcw className="h-3 w-3" /> Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Locked state */}
|
|
{isLocked ? (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<div className="relative text-center max-w-sm px-4">
|
|
<div className="absolute inset-0 rounded-3xl bg-indigo-500/5 blur-3xl -z-10" />
|
|
<div className="relative">
|
|
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500/15 to-violet-500/10 border border-indigo-500/20 mx-auto mb-5 shadow-xl shadow-indigo-500/5">
|
|
<Lock className="h-9 w-9 text-indigo-400" />
|
|
</div>
|
|
<h3 className="text-xl font-bold text-white mb-2">AI requires Pro plan</h3>
|
|
<p className="text-sm text-white/45 mb-8 leading-relaxed">
|
|
Upgrade to unlock AI-powered insights about your properties, tenants, rent collection, and more.
|
|
</p>
|
|
<div className="space-y-3">
|
|
<Link
|
|
href="/settings/billing"
|
|
className="flex items-center justify-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-xl hover:shadow-indigo-500/25"
|
|
>
|
|
<Sparkles className="h-4 w-4" />
|
|
Upgrade to Pro — 50 AI calls/mo
|
|
</Link>
|
|
<Link
|
|
href="/settings/demo"
|
|
className="block text-xs text-white/30 hover:text-white/60 transition"
|
|
>
|
|
Testing? Switch plan in Demo Data →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Chat area */}
|
|
<div className="flex-1 overflow-y-auto space-y-5 pr-1 pb-4">
|
|
{messages.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-full gap-7 text-center">
|
|
<div>
|
|
<div className="relative mx-auto mb-5 h-20 w-20">
|
|
<div className="absolute inset-0 rounded-2xl bg-indigo-500/10 blur-xl" />
|
|
<div className="relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500/20 to-violet-500/10 border border-indigo-500/20">
|
|
<Bot className="h-9 w-9 text-indigo-300" />
|
|
</div>
|
|
</div>
|
|
<h3 className="text-xl font-bold text-white mb-2">How can I help?</h3>
|
|
<p className="text-sm text-white/40 max-w-xs leading-relaxed">
|
|
I have full access to your live portfolio — properties, tenants, payments, maintenance, and leases.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="w-full max-w-lg">
|
|
<p className="text-[10px] font-semibold uppercase tracking-widest text-white/25 mb-3">Try asking</p>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
{SUGGESTED.map((q) => (
|
|
<button
|
|
key={q}
|
|
onClick={() => send(q)}
|
|
className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-3 text-left text-xs text-white/55 hover:border-indigo-500/30 hover:bg-indigo-500/5 hover:text-white/90 transition-all duration-150"
|
|
>
|
|
{q}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{messages.map((msg, i) => (
|
|
<MessageBubble key={i} msg={msg} />
|
|
))}
|
|
{loading && (
|
|
<div className="flex gap-3 items-start">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 mt-0.5">
|
|
<Bot className="h-4 w-4 text-white" />
|
|
</div>
|
|
<div className="rounded-2xl rounded-tl-sm bg-[#1d1d2a] border border-white/[0.06] px-4 py-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<Loader2 className="h-3.5 w-3.5 text-indigo-400 animate-spin" />
|
|
<span className="text-xs text-white/35">Analysing your portfolio…</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={bottomRef} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="shrink-0 mt-2">
|
|
{isExhausted && (
|
|
<div className="mb-2 rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-2.5 text-center text-xs text-red-400">
|
|
Monthly limit reached. <Link href="/settings/billing" className="font-semibold underline underline-offset-2">Upgrade for more calls →</Link>
|
|
</div>
|
|
)}
|
|
<div className={`relative flex items-end gap-2 rounded-2xl border bg-[#16161f] p-3 transition-colors ${
|
|
isExhausted ? "border-red-500/20 opacity-60" : "border-white/[0.08] focus-within:border-indigo-500/40"
|
|
}`}>
|
|
<textarea
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKey}
|
|
placeholder={isExhausted ? "Monthly limit reached" : "Ask anything about your portfolio…"}
|
|
rows={1}
|
|
disabled={loading || isExhausted}
|
|
className="flex-1 resize-none bg-transparent text-sm text-white placeholder:text-white/25 focus:outline-none min-h-[24px] max-h-32 leading-6 disabled:cursor-not-allowed"
|
|
onInput={(e) => {
|
|
const el = e.currentTarget
|
|
el.style.height = "auto"
|
|
el.style.height = `${Math.min(el.scrollHeight, 128)}px`
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={() => send(input)}
|
|
disabled={!input.trim() || loading || isExhausted}
|
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white transition-all hover:bg-indigo-500 hover:shadow-md hover:shadow-indigo-500/30 disabled:opacity-35 disabled:cursor-not-allowed"
|
|
>
|
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
<p className="mt-1.5 text-center text-[10px] text-white/20">
|
|
Enter to send · Shift+Enter for new line{!isLocked && ` · ${limit - currentUsed} calls left this month`}
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|