Files
property-management-network/app/(dashboard)/ai/ai-chat.tsx
T
Leon SerfatyandClaude Opus 4.8 857b9a7811 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>
2026-06-23 20:36:07 -04:00

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>
)
}