Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening

Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-02 13:42:34 -04:00
co-authored by Claude Opus 4.8
parent 969d5d4c8a
commit c9968531e4
282 changed files with 41530 additions and 4013 deletions
+96
View File
@@ -0,0 +1,96 @@
"use client"
import { useState, useTransition } from "react"
import * as Switch from "@radix-ui/react-switch"
import { toast } from "sonner"
import { Power } from "lucide-react"
import { setSiteMaintenance } from "@/app/actions/admin"
export function MaintenanceToggle({
initialEnabled,
initialMessage,
}: {
initialEnabled: boolean
initialMessage: string
}) {
const [enabled, setEnabled] = useState(initialEnabled)
const [message, setMessage] = useState(initialMessage)
const [pending, startTransition] = useTransition()
function persist(nextEnabled: boolean, nextMessage: string) {
startTransition(async () => {
try {
await setSiteMaintenance(nextEnabled, nextMessage)
toast.success(
nextEnabled
? "Maintenance mode ON — the site is offline for everyone except admins."
: "Maintenance mode OFF — the site is live."
)
} catch {
setEnabled(!nextEnabled) // revert optimistic toggle
toast.error("Couldn't update maintenance mode. Try again.")
}
})
}
function onToggle(next: boolean) {
setEnabled(next)
persist(next, message)
}
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<Power className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">Maintenance mode</h2>
</div>
<div className="px-5 py-4 space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-white/80">
{enabled ? "Site is offline" : "Site is live"}
</p>
<p className="mt-0.5 text-xs text-white/40">
When on, all visitors see a maintenance page. Admins keep full access.
</p>
</div>
<Switch.Root
checked={enabled}
onCheckedChange={onToggle}
disabled={pending}
aria-label="Toggle maintenance mode"
className="relative h-6 w-11 shrink-0 cursor-pointer rounded-full bg-white/10 outline-none transition-colors data-[state=checked]:bg-rose-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Switch.Thumb className="block h-5 w-5 translate-x-0.5 rounded-full bg-white shadow transition-transform will-change-transform data-[state=checked]:translate-x-[22px]" />
</Switch.Root>
</div>
<div>
<label htmlFor="maintenance-message" className="mb-1.5 block text-xs font-medium text-white/50">
Message shown to visitors (optional)
</label>
<textarea
id="maintenance-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
placeholder="We're upgrading the platform and will be back shortly."
className="w-full resize-none rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/25 outline-none transition focus:border-rose-500/50 focus:ring-1 focus:ring-rose-500/30"
/>
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={() => persist(enabled, message)}
disabled={pending}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-white/80 transition hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed"
>
Save message
</button>
</div>
</div>
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import Script from "next/script"
// Umami — privacy-friendly, cookieless web analytics.
//
// Loads only in production so local dev traffic isn't tracked. The script src
// and website id are public by design (they appear in the served HTML), so the
// defaults are hardcoded here; override them via NEXT_PUBLIC_UMAMI_SRC /
// NEXT_PUBLIC_UMAMI_WEBSITE_ID to point at a different instance, or set the id
// to an empty string to disable. Umami v2 auto-tracks client-side route
// changes, so Next.js navigations are captured without extra wiring.
const UMAMI_SRC =
process.env.NEXT_PUBLIC_UMAMI_SRC || "https://fickanalytics.phluit.net/script.js"
const UMAMI_WEBSITE_ID =
process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID ?? "4066c359-596f-4d0e-9636-c035c2adfbe8"
export function UmamiAnalytics() {
// Never track local development.
if (process.env.NODE_ENV !== "production") return null
if (!UMAMI_SRC || !UMAMI_WEBSITE_ID) return null
return (
<Script
src={UMAMI_SRC}
data-website-id={UMAMI_WEBSITE_ID}
strategy="afterInteractive"
defer
/>
)
}
@@ -0,0 +1,164 @@
"use client"
import { useEffect, useState, useTransition } from "react"
import { toast } from "sonner"
import { RefreshCw, Link2, CheckCircle2, AlertTriangle, BookOpen } from "lucide-react"
import { formatDate } from "@/lib/utils"
import { syncAccountingNow, disconnectAccounting } from "@/app/actions/accounting"
type Conn = { provider: string; orgName: string | null; status: string; lastSyncAt: string | null; lastError: string | null }
type Prov = { id: string; label: string; configured: boolean }
const ERR_MSG: Record<string, string> = {
connect_failed: "Connection failed — please try again.",
invalid_state: "The callback was invalid. Please retry the connection.",
owner_only: "Only the account owner can manage integrations.",
not_configured: "That provider isn't configured on the server yet.",
unknown_provider: "Unknown provider.",
}
export function AccountingIntegrations({
providers,
connections,
isOwner,
flash,
}: {
providers: Prov[]
connections: Conn[]
isOwner: boolean
flash: { connected?: string; error?: string }
}) {
const connByProvider: Record<string, Conn> = Object.fromEntries(connections.map((c) => [c.provider, c]))
const [pending, start] = useTransition()
const [busy, setBusy] = useState<string | null>(null)
useEffect(() => {
if (flash.connected) toast.success(`Connected to ${flash.connected === "quickbooks" ? "QuickBooks" : "Xero"}`)
if (flash.error) toast.error(ERR_MSG[flash.error] ?? "Something went wrong")
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function sync(id: string) {
setBusy(id)
start(async () => {
try {
const r = await syncAccountingNow(id)
toast.success(`Synced ${r.income} income + ${r.expense} expense entries`)
} catch (e) {
toast.error((e as Error).message || "Sync failed")
} finally {
setBusy(null)
}
})
}
function remove(id: string) {
if (!confirm("Disconnect this integration? Future income/expenses won't sync until you reconnect.")) return
setBusy(id)
start(async () => {
try {
await disconnectAccounting(id)
toast.success("Disconnected")
} catch {
toast.error("Couldn't disconnect")
} finally {
setBusy(null)
}
})
}
if (!isOwner) {
return (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-6 text-sm text-white/50">
Only the account owner can manage accounting integrations.
</div>
)
}
return (
<div className="space-y-3">
{providers.map((p) => {
const conn = connByProvider[p.id]
const isBusy = pending && busy === p.id
return (
<div key={p.id} className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/[0.04] text-white/70">
<BookOpen className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold text-white">{p.label}</p>
{conn ? (
<p className="text-xs text-white/40">
{conn.orgName ?? "Connected"} · Last sync {conn.lastSyncAt ? formatDate(conn.lastSyncAt) : "never"}
</p>
) : (
<p className="text-xs text-white/40">Push rent income &amp; expenses to {p.label}</p>
)}
</div>
</div>
{conn ? (
<span
className={`flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium ${
conn.status === "error"
? "border-red-500/20 bg-red-500/10 text-red-400"
: "border-emerald-500/20 bg-emerald-500/10 text-emerald-400"
}`}
>
{conn.status === "error" ? <AlertTriangle className="h-3 w-3" /> : <CheckCircle2 className="h-3 w-3" />}
{conn.status === "error" ? "Error" : "Connected"}
</span>
) : p.configured ? null : (
<span className="shrink-0 rounded-full border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] font-medium text-amber-400">
Not configured
</span>
)}
</div>
{conn?.status === "error" && conn.lastError && (
<p className="mt-3 rounded-lg border border-red-500/15 bg-red-500/[0.06] px-3 py-2 text-xs text-red-300/90">{conn.lastError}</p>
)}
<div className="mt-4 flex items-center gap-2">
{conn ? (
<>
<button
onClick={() => sync(p.id)}
disabled={isBusy}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${isBusy ? "animate-spin" : ""}`} /> Sync now
</button>
<button
onClick={() => remove(p.id)}
disabled={isBusy}
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60 transition hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
>
Disconnect
</button>
</>
) : p.configured ? (
<a
href={`/api/integrations/${p.id}/connect`}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500"
>
<Link2 className="h-3.5 w-3.5" /> Connect {p.label}
</a>
) : (
<p className="text-xs text-white/30">
Set this provider&apos;s OAuth credentials on the server to enable connecting.
</p>
)}
</div>
</div>
)
})}
<p className="px-1 text-[11px] text-white/30">
One-way sync paid rent is pushed as income and expenses as bills/purchases. We never read or modify existing data in your books.
</p>
</div>
)
}
+218
View File
@@ -0,0 +1,218 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Check, Copy, KeyRound, Loader2, Plus, Trash2, TriangleAlert } from "lucide-react"
import { createApiKey, revokeApiKey } from "@/app/actions/api-keys"
export type ApiKeyRow = {
id: string
name: string
key_prefix: string
created_at: string
last_used_at: string | null
revoked_at: string | null
}
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"
function formatDate(value: string | null): string {
if (!value) return "Never"
const d = new Date(value)
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
}
export function ApiKeyManager({ initialKeys }: { initialKeys: ApiKeyRow[] }) {
const router = useRouter()
const [name, setName] = useState("")
const [creating, setCreating] = useState(false)
const [busyId, setBusyId] = useState<string | null>(null)
const [newKey, setNewKey] = useState<{ plaintext: string; prefix: string } | null>(null)
const [copied, setCopied] = useState(false)
async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
setCreating(true)
try {
const result = await createApiKey(trimmed)
setNewKey(result)
setName("")
setCopied(false)
toast.success("API key created")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to create API key")
} finally {
setCreating(false)
}
}
async function handleCopy() {
if (!newKey) return
try {
await navigator.clipboard.writeText(newKey.plaintext)
setCopied(true)
toast.success("Copied to clipboard")
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error("Copy failed — select and copy the key manually")
}
}
async function handleRevoke(key: ApiKeyRow) {
setBusyId(key.id)
try {
await revokeApiKey(key.id)
toast.success(`Revoked "${key.name}"`)
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to revoke key")
} finally {
setBusyId(null)
}
}
return (
<div className="space-y-6">
{/* One-time plaintext reveal */}
{newKey && (
<div className="rounded-xl border border-amber-500/25 bg-amber-500/[0.06] p-6">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-amber-500/10 p-2">
<TriangleAlert className="h-5 w-5 text-amber-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">Copy your new API key now</p>
<p className="mt-0.5 text-xs text-amber-200/70">
This is the only time it will be shown. Store it somewhere safe you will not see it again.
</p>
<div className="mt-3 flex items-center gap-2">
<code className="min-w-0 flex-1 overflow-x-auto rounded-lg border border-white/10 bg-[#0a0a12] px-3 py-2.5 font-mono text-xs text-emerald-300">
{newKey.plaintext}
</code>
<button
type="button"
onClick={handleCopy}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2.5 text-xs font-medium text-white/70 transition hover:bg-white/5"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-emerald-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copied ? "Copied" : "Copy"}
</button>
</div>
<button
type="button"
onClick={() => setNewKey(null)}
className="mt-3 text-xs font-medium text-white/40 transition hover:text-white/70"
>
I&apos;ve saved it dismiss
</button>
</div>
</div>
</div>
)}
{/* Create form */}
<form
onSubmit={handleCreate}
className="space-y-4 rounded-xl border border-white/[0.06] bg-[#16161f] p-6"
>
<div>
<h3 className="text-sm font-semibold text-white">Create an API key</h3>
<p className="mt-0.5 text-xs text-white/40">
Use API keys to authenticate requests to the public REST API.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<input
type="text"
required
maxLength={100}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Production integration"
className={inputClass + " sm:flex-1"}
/>
<button
type="submit"
disabled={creating || !name.trim()}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50 sm:w-auto"
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
Create key
</button>
</div>
</form>
{/* Keys list */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f]">
<div className="border-b border-white/[0.06] px-6 py-4">
<h3 className="text-sm font-semibold text-white">
Your API keys <span className="text-white/30">({initialKeys.length})</span>
</h3>
</div>
{initialKeys.length === 0 ? (
<div className="px-6 py-10 text-center text-sm text-white/40">
No API keys yet. Create one above to start using the REST API.
</div>
) : (
<ul className="divide-y divide-white/[0.06]">
{initialKeys.map((key) => {
const busy = busyId === key.id
const revoked = !!key.revoked_at
return (
<li
key={key.id}
className="flex flex-wrap items-center justify-between gap-3 px-6 py-4"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 shrink-0 text-white/30" />
<p className="truncate text-sm font-medium text-white">{key.name}</p>
{revoked && (
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white/40">
Revoked
</span>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 pl-6 text-xs text-white/40">
<code className="font-mono text-white/60">{key.key_prefix}</code>
<span>Created {formatDate(key.created_at)}</span>
<span>Last used {formatDate(key.last_used_at)}</span>
</div>
</div>
{!revoked && (
<button
type="button"
onClick={() => handleRevoke(key)}
disabled={busy}
className="inline-flex items-center gap-1.5 rounded-lg border border-red-500/20 px-3 py-1.5 text-xs font-medium text-red-400 transition hover:bg-red-500/10 disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Revoke
</button>
)}
</li>
)
})}
</ul>
)}
</div>
</div>
)
}
+8 -6
View File
@@ -7,10 +7,10 @@ import { formatDate } from "@/lib/utils"
interface Notification {
id: string
type: string
title: string
body: string
subject: string
read: boolean
created_at: string
sent_at: string
metadata?: { body?: string } & Record<string, unknown>
}
const typeIcon: Record<string, React.ElementType> = {
@@ -127,9 +127,11 @@ export function NotificationsBell() {
<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>
<p className={`text-sm font-medium leading-tight ${n.read ? "text-white/60" : "text-white"}`}>{n.subject}</p>
{typeof n.metadata?.body === "string" && n.metadata.body && (
<p className="text-xs text-white/35 mt-0.5 line-clamp-2">{n.metadata.body}</p>
)}
<p className="text-[10px] text-white/20 mt-1">{formatDate(n.sent_at)}</p>
</div>
{!n.read && <div className="h-2 w-2 rounded-full bg-indigo-500 shrink-0 mt-1.5" />}
</div>
+127 -12
View File
@@ -7,7 +7,7 @@ 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,
PanelLeftClose, PanelLeftOpen, CalendarDays, Activity, Brain, Bell, Palette, KeyRound, Plug, Webhook,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Logo, LogoMark } from "@/components/shared/logo"
@@ -43,16 +43,18 @@ const planConfig: Record<string, { label: string; color: string; bg: string; bor
lifetime: { label: "Lifetime", color: "text-amber-300", bg: "bg-amber-500/10", border: "border-amber-500/20" },
}
interface SidebarProps { profile: Profile | null }
interface SidebarProps { profile: Profile | null; isAdmin?: boolean }
function NavContent({
profile,
collapsed,
onClose,
isAdmin,
}: {
profile: Profile | null
collapsed?: boolean
onClose?: () => void
isAdmin?: boolean
}) {
const pathname = usePathname()
@@ -150,27 +152,140 @@ function NavContent({
{!collapsed && "Settings"}
</Link>
{/* Demo Data is an admin-only testing tool (seed sample data + plan
switcher). Hidden from regular users; access is also enforced
server-side in the page and actions. */}
{isAdmin && (
<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>
)}
<Link
href="/settings/demo"
href="/settings/api-keys"
onClick={onClose}
title={collapsed ? "Demo Data" : undefined}
title={collapsed ? "API keys" : 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"
pathname === "/settings/api-keys"
? "bg-indigo-600/15 text-indigo-300"
: "text-white/50 hover:bg-white/[0.05] hover:text-white/90"
)}
>
{pathname === "/settings/demo" && (
{pathname === "/settings/api-keys" && (
<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(
<KeyRound className={cn(
"h-4 w-4 shrink-0 transition-colors",
pathname === "/settings/demo" ? "text-indigo-400" : "text-white/30 group-hover:text-white/60"
pathname === "/settings/api-keys" ? "text-indigo-400" : "text-white/30 group-hover:text-white/60"
)} />
{!collapsed && "Demo Data"}
{!collapsed && "API keys"}
</Link>
<Link
href="/settings/integrations"
onClick={onClose}
title={collapsed ? "Integrations" : 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/integrations"
? "bg-indigo-600/15 text-indigo-300"
: "text-white/50 hover:bg-white/[0.05] hover:text-white/90"
)}
>
{pathname === "/settings/integrations" && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full bg-indigo-500" />
)}
<Plug className={cn(
"h-4 w-4 shrink-0 transition-colors",
pathname === "/settings/integrations" ? "text-indigo-400" : "text-white/30 group-hover:text-white/60"
)} />
{!collapsed && "Integrations"}
</Link>
<Link
href="/settings/webhooks"
onClick={onClose}
title={collapsed ? "Webhooks" : 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/webhooks"
? "bg-indigo-600/15 text-indigo-300"
: "text-white/50 hover:bg-white/[0.05] hover:text-white/90"
)}
>
{pathname === "/settings/webhooks" && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full bg-indigo-500" />
)}
<Webhook className={cn(
"h-4 w-4 shrink-0 transition-colors",
pathname === "/settings/webhooks" ? "text-indigo-400" : "text-white/30 group-hover:text-white/60"
)} />
{!collapsed && "Webhooks"}
</Link>
{(plan === "landlord" || plan === "lifetime") && (
<>
<Link
href="/settings/branding"
onClick={onClose}
title={collapsed ? "Branding" : 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/branding"
? "bg-indigo-600/15 text-indigo-300"
: "text-white/50 hover:bg-white/[0.05] hover:text-white/90"
)}
>
<Palette className={cn(
"h-4 w-4 shrink-0 transition-colors",
pathname === "/settings/branding" ? "text-indigo-400" : "text-white/30 group-hover:text-white/60"
)} />
{!collapsed && "Branding"}
</Link>
<Link
href="/settings/team"
onClick={onClose}
title={collapsed ? "Team" : 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/team"
? "bg-indigo-600/15 text-indigo-300"
: "text-white/50 hover:bg-white/[0.05] hover:text-white/90"
)}
>
<Users className={cn(
"h-4 w-4 shrink-0 transition-colors",
pathname === "/settings/team" ? "text-indigo-400" : "text-white/30 group-hover:text-white/60"
)} />
{!collapsed && "Team"}
</Link>
</>
)}
</nav>
{/* Bottom — plan + user */}
@@ -239,7 +354,7 @@ function NavContent({
)
}
export function Sidebar({ profile }: SidebarProps) {
export function Sidebar({ profile, isAdmin }: SidebarProps) {
const [mobileOpen, setMobileOpen] = useState(false)
const [collapsed, setCollapsed] = useState(false)
@@ -264,7 +379,7 @@ export function Sidebar({ profile }: SidebarProps) {
collapsed ? "w-16" : "w-60"
)}
>
<NavContent profile={profile} collapsed={collapsed} />
<NavContent profile={profile} collapsed={collapsed} isAdmin={isAdmin} />
{/* Collapse toggle */}
<button
@@ -298,7 +413,7 @@ export function Sidebar({ profile }: SidebarProps) {
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)} />
<NavContent profile={profile} onClose={() => setMobileOpen(false)} isAdmin={isAdmin} />
</aside>
</div>
)}
+221
View File
@@ -0,0 +1,221 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Loader2, Trash2, UserPlus } from "lucide-react"
type MemberRole = "member" | "viewer"
type MemberStatus = "pending" | "active" | "revoked"
export type TeamMember = {
id: string
email: string
role: MemberRole
status: MemberStatus
}
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"
export function TeamManager({ initialMembers }: { initialMembers: TeamMember[] }) {
const router = useRouter()
const [members, setMembers] = useState<TeamMember[]>(initialMembers)
const [email, setEmail] = useState("")
const [role, setRole] = useState<MemberRole>("member")
const [inviting, setInviting] = useState(false)
const [busyId, setBusyId] = useState<string | null>(null)
async function handleInvite(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!email.trim()) return
setInviting(true)
const res = await fetch("/api/team", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), role }),
})
setInviting(false)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
toast.error(typeof data.error === "string" ? data.error : "Failed to send invite")
return
}
toast.success(`Invite sent to ${email.trim()}`)
setEmail("")
setRole("member")
router.refresh()
}
async function handleRoleToggle(member: TeamMember) {
const nextRole: MemberRole = member.role === "member" ? "viewer" : "member"
setBusyId(member.id)
const res = await fetch(`/api/team/${member.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: nextRole }),
})
setBusyId(null)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
toast.error(typeof data.error === "string" ? data.error : "Failed to update role")
return
}
setMembers((prev) =>
prev.map((m) => (m.id === member.id ? { ...m, role: nextRole } : m))
)
toast.success("Role updated")
router.refresh()
}
async function handleRevoke(member: TeamMember) {
setBusyId(member.id)
const res = await fetch(`/api/team/${member.id}`, { method: "DELETE" })
setBusyId(null)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
toast.error(typeof data.error === "string" ? data.error : "Failed to revoke access")
return
}
setMembers((prev) => prev.filter((m) => m.id !== member.id))
toast.success(`Removed ${member.email}`)
router.refresh()
}
return (
<div className="space-y-6">
{/* Invite form */}
<form
onSubmit={handleInvite}
className="space-y-4 rounded-xl border border-white/[0.06] bg-[#16161f] p-6"
>
<div>
<h3 className="text-sm font-semibold text-white">Invite a team member</h3>
<p className="mt-0.5 text-xs text-white/40">
They&apos;ll get an email with a link to join your account.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="teammate@example.com"
className={inputClass + " sm:flex-1"}
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as MemberRole)}
className={inputClass + " sm:w-40"}
>
<option value="member">Member</option>
<option value="viewer">Viewer</option>
</select>
<button
type="submit"
disabled={inviting}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50 sm:w-auto"
>
{inviting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<UserPlus className="h-4 w-4" />
)}
Invite
</button>
</div>
<p className="text-xs text-white/30">
Members can view and edit your portfolio. Viewers have read-only access.
</p>
</form>
{/* Members list */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f]">
<div className="border-b border-white/[0.06] px-6 py-4">
<h3 className="text-sm font-semibold text-white">
Team members{" "}
<span className="text-white/30">({members.length})</span>
</h3>
</div>
{members.length === 0 ? (
<div className="px-6 py-10 text-center text-sm text-white/40">
No team members yet. Invite someone above to get started.
</div>
) : (
<ul className="divide-y divide-white/[0.06]">
{members.map((member) => {
const busy = busyId === member.id
return (
<li
key={member.id}
className="flex flex-wrap items-center justify-between gap-3 px-6 py-4"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{member.email}</p>
<div className="mt-1 flex items-center gap-2">
<StatusBadge status={member.status} />
<span className="text-xs capitalize text-white/40">{member.role}</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleRoleToggle(member)}
disabled={busy}
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 transition hover:bg-white/5 disabled:opacity-50"
>
Make {member.role === "member" ? "viewer" : "member"}
</button>
<button
type="button"
onClick={() => handleRevoke(member)}
disabled={busy}
className="inline-flex items-center gap-1.5 rounded-lg border border-red-500/20 px-3 py-1.5 text-xs font-medium text-red-400 transition hover:bg-red-500/10 disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Revoke
</button>
</div>
</li>
)
})}
</ul>
)}
</div>
</div>
)
}
function StatusBadge({ status }: { status: MemberStatus }) {
const styles: Record<MemberStatus, string> = {
active: "border-emerald-500/20 bg-emerald-500/10 text-emerald-400",
pending: "border-amber-500/20 bg-amber-500/10 text-amber-400",
revoked: "border-white/10 bg-white/5 text-white/40",
}
return (
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${styles[status]}`}
>
{status}
</span>
)
}
+357
View File
@@ -0,0 +1,357 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import {
Check,
Copy,
Eye,
EyeOff,
Loader2,
Plus,
Power,
RefreshCw,
Send,
Trash2,
Webhook,
} from "lucide-react"
import { WEBHOOK_EVENTS } from "@/lib/webhooks/events"
import {
createWebhookEndpoint,
deleteWebhookEndpoint,
rotateWebhookSecret,
sendTestWebhook,
updateWebhookEndpoint,
type WebhookEndpointDTO,
} from "@/app/actions/webhooks"
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"
function formatDate(value: string | null): string {
if (!value) return "Never"
const d = new Date(value)
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
}
function eventsSummary(events: string[]): string {
if (events.length === 0) return "All events"
if (events.length === 1) return events[0]
return `${events.length} events`
}
function SecretField({ secret }: { secret: string }) {
const [shown, setShown] = useState(false)
const [copied, setCopied] = useState(false)
async function copy() {
try {
await navigator.clipboard.writeText(secret)
setCopied(true)
toast.success("Signing secret copied")
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error("Copy failed — reveal and copy manually")
}
}
return (
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-lg border border-white/10 bg-[#0a0a12] px-3 py-2 font-mono text-xs text-emerald-300">
{shown ? secret : "whsec_" + "•".repeat(24)}
</code>
<button
type="button"
onClick={() => setShown((s) => !s)}
title={shown ? "Hide" : "Reveal"}
className="shrink-0 rounded-lg border border-white/10 p-2 text-white/50 transition hover:bg-white/5 hover:text-white"
>
{shown ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={copy}
title="Copy"
className="shrink-0 rounded-lg border border-white/10 p-2 text-white/50 transition hover:bg-white/5 hover:text-white"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
)
}
export function WebhookManager({ initialEndpoints }: { initialEndpoints: WebhookEndpointDTO[] }) {
const router = useRouter()
const [url, setUrl] = useState("")
const [description, setDescription] = useState("")
const [allEvents, setAllEvents] = useState(true)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [creating, setCreating] = useState(false)
const [busyId, setBusyId] = useState<string | null>(null)
function toggleEvent(id: string) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const trimmed = url.trim()
if (!trimmed) return
setCreating(true)
try {
await createWebhookEndpoint({
url: trimmed,
description: description.trim() || undefined,
events: allEvents ? [] : Array.from(selected),
})
setUrl("")
setDescription("")
setAllEvents(true)
setSelected(new Set())
toast.success("Webhook endpoint created")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to create webhook")
} finally {
setCreating(false)
}
}
async function withBusy(id: string, fn: () => Promise<void>) {
setBusyId(id)
try {
await fn()
} finally {
setBusyId(null)
}
}
async function handleTest(ep: WebhookEndpointDTO) {
await withBusy(ep.id, async () => {
try {
const res = await sendTestWebhook(ep.id)
if (res.ok) toast.success(`Test delivered (HTTP ${res.responseStatus})`)
else toast.error(`Test failed: ${res.error ?? "no response"}`)
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Test failed")
}
})
}
async function handleToggle(ep: WebhookEndpointDTO) {
await withBusy(ep.id, async () => {
try {
await updateWebhookEndpoint(ep.id, { status: ep.status === "active" ? "disabled" : "active" })
toast.success(ep.status === "active" ? "Webhook disabled" : "Webhook enabled")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Update failed")
}
})
}
async function handleRotate(ep: WebhookEndpointDTO) {
if (!confirm("Rotate the signing secret? The current secret stops working immediately.")) return
await withBusy(ep.id, async () => {
try {
await rotateWebhookSecret(ep.id)
toast.success("Signing secret rotated")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Rotate failed")
}
})
}
async function handleDelete(ep: WebhookEndpointDTO) {
if (!confirm(`Delete webhook for ${ep.url}? This cannot be undone.`)) return
await withBusy(ep.id, async () => {
try {
await deleteWebhookEndpoint(ep.id)
toast.success("Webhook deleted")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Delete failed")
}
})
}
return (
<div className="space-y-6">
{/* Create form */}
<form
onSubmit={handleCreate}
className="space-y-4 rounded-xl border border-white/[0.06] bg-[#16161f] p-6"
>
<div>
<h3 className="text-sm font-semibold text-white">Add an endpoint</h3>
<p className="mt-0.5 text-xs text-white/40">
We&apos;ll POST a signed JSON payload to this URL whenever a subscribed event happens.
</p>
</div>
<input
type="url"
required
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://hooks.zapier.com/hooks/catch/…"
className={inputClass}
/>
<input
type="text"
maxLength={200}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional) — e.g. Zapier: new tenant → Slack"
className={inputClass}
/>
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] p-4">
<label className="flex cursor-pointer items-center gap-2.5">
<input
type="checkbox"
checked={allEvents}
onChange={(e) => setAllEvents(e.target.checked)}
className="h-4 w-4 rounded border-white/20 bg-white/5 text-indigo-500 focus:ring-indigo-500"
/>
<span className="text-sm font-medium text-white">Send all events</span>
</label>
{!allEvents && (
<div className="mt-3 grid gap-2 border-t border-white/[0.06] pt-3 sm:grid-cols-2">
{WEBHOOK_EVENTS.map((ev) => (
<label key={ev.id} className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={selected.has(ev.id)}
onChange={() => toggleEvent(ev.id)}
className="mt-0.5 h-4 w-4 rounded border-white/20 bg-white/5 text-indigo-500 focus:ring-indigo-500"
/>
<span>
<code className="text-xs font-mono text-indigo-300">{ev.id}</code>
<span className="block text-[11px] text-white/40">{ev.label}</span>
</span>
</label>
))}
</div>
)}
</div>
<button
type="submit"
disabled={creating || !url.trim() || (!allEvents && selected.size === 0)}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50"
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
Add endpoint
</button>
</form>
{/* Endpoints list */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f]">
<div className="border-b border-white/[0.06] px-6 py-4">
<h3 className="text-sm font-semibold text-white">
Endpoints <span className="text-white/30">({initialEndpoints.length})</span>
</h3>
</div>
{initialEndpoints.length === 0 ? (
<div className="px-6 py-10 text-center text-sm text-white/40">
No endpoints yet. Add one above to start receiving events.
</div>
) : (
<ul className="divide-y divide-white/[0.06]">
{initialEndpoints.map((ep) => {
const busy = busyId === ep.id
const disabled = ep.status === "disabled"
return (
<li key={ep.id} className="space-y-3 px-6 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Webhook className="h-4 w-4 shrink-0 text-white/30" />
<p className="truncate text-sm font-medium text-white">{ep.url}</p>
{disabled && (
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white/40">
Disabled
</span>
)}
{ep.source === "zapier" && (
<span className="rounded-full border border-orange-500/20 bg-orange-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-orange-300">
Zapier
</span>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 pl-6 text-xs text-white/40">
<span>{eventsSummary(ep.events)}</span>
<span>Last success {formatDate(ep.last_success_at)}</span>
{ep.failure_count > 0 && (
<span className="text-red-400">{ep.failure_count} recent failure{ep.failure_count > 1 ? "s" : ""}</span>
)}
</div>
{ep.description && (
<p className="mt-1 pl-6 text-xs text-white/30">{ep.description}</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => handleTest(ep)}
disabled={busy}
title="Send test event"
className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1.5 text-xs font-medium text-white/70 transition hover:bg-white/5 disabled:opacity-50"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
Test
</button>
<button
type="button"
onClick={() => handleToggle(ep)}
disabled={busy}
title={disabled ? "Enable" : "Disable"}
className="rounded-lg border border-white/10 p-2 text-white/50 transition hover:bg-white/5 hover:text-white disabled:opacity-50"
>
<Power className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => handleRotate(ep)}
disabled={busy}
title="Rotate signing secret"
className="rounded-lg border border-white/10 p-2 text-white/50 transition hover:bg-white/5 hover:text-white disabled:opacity-50"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => handleDelete(ep)}
disabled={busy}
title="Delete"
className="rounded-lg border border-red-500/20 p-2 text-red-400 transition hover:bg-red-500/10 disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<SecretField secret={ep.secret} />
</li>
)
})}
</ul>
)}
</div>
</div>
)
}
+250
View File
@@ -0,0 +1,250 @@
"use client"
import { useState, useRef, useTransition } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Camera, Loader2, X, Home } from "lucide-react"
import { updateBranding } from "@/app/actions/branding"
const HEX_RE = /^#[0-9a-fA-F]{6}$/
interface Props {
brandName: string | null
brandLogoUrl: string | null
brandColor: string | null
hidePoweredBy: boolean
}
export function BrandingForm({ brandName, brandLogoUrl, brandColor, hidePoweredBy }: Props) {
const router = useRouter()
const fileRef = useRef<HTMLInputElement>(null)
const [isPending, startTransition] = useTransition()
const [name, setName] = useState(brandName ?? "")
const [logoUrl, setLogoUrl] = useState<string | null>(brandLogoUrl)
const [color, setColor] = useState(brandColor ?? "#4f46e5")
const [hidePb, setHidePb] = useState(hidePoweredBy)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState("")
const validColor = HEX_RE.test(color)
const previewColor = validColor ? color : "#4f46e5"
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 handleLogo(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("Logo must be under 5MB")
return
}
setUploading(true)
try {
const fd = new FormData()
fd.append("file", file)
fd.append("scope", "misc")
const res = await fetch("/api/upload", { method: "POST", body: fd })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(typeof data.error === "string" ? data.error : "Upload failed")
}
const { url } = await res.json()
setLogoUrl(url)
toast.success("Logo uploaded")
} catch (err: any) {
toast.error(err?.message ?? "Upload failed")
} finally {
setUploading(false)
if (fileRef.current) fileRef.current.value = ""
}
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setError("")
if (color.trim() && !validColor) {
setError("Accent color must be a hex value like #4f46e5")
return
}
const fd = new FormData()
fd.set("brand_name", name)
fd.set("brand_logo_url", logoUrl ?? "")
fd.set("brand_color", color.trim() && validColor ? color : "")
if (hidePb) fd.set("hide_powered_by", "on")
startTransition(async () => {
try {
await updateBranding(fd)
toast.success("Branding saved")
router.refresh()
} catch (err: any) {
const msg = err?.message ?? "Failed to save branding"
setError(msg)
toast.error(msg)
}
})
}
return (
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<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}>Brand Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={60}
placeholder="Acme Property Management"
className={inputClass}
/>
<p className="mt-1 text-xs text-white/30">Shown in the tenant portal header instead of Tenant Portal.</p>
</div>
<div>
<label className={labelClass}>Logo</label>
{logoUrl ? (
<div className="group relative h-28 w-full overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logoUrl} alt="Brand logo" className="h-full w-full object-contain p-3" />
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/50 opacity-0 transition group-hover:opacity-100">
<button
type="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 transition hover:bg-white/20"
>
<Camera className="h-3.5 w-3.5" /> Change
</button>
<button
type="button"
onClick={() => setLogoUrl(null)}
disabled={uploading}
className="flex items-center gap-1.5 rounded-lg bg-red-500/20 px-3 py-2 text-xs text-red-400 transition hover:bg-red-500/30"
>
<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
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="flex h-28 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 transition hover:border-indigo-500/40 hover:text-white/50 disabled:opacity-50"
>
{uploading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<>
<Camera className="h-6 w-6" />
<span className="text-xs">Upload logo</span>
</>
)}
</button>
)}
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleLogo} />
<p className="mt-1 text-xs text-white/30">PNG or SVG works best. Max 5MB.</p>
</div>
<div>
<label className={labelClass}>Accent Color</label>
<div className="flex items-center gap-3">
<input
type="color"
value={previewColor}
onChange={(e) => setColor(e.target.value)}
className="h-10 w-12 shrink-0 cursor-pointer rounded-lg border border-white/10 bg-white/5 p-1"
aria-label="Accent color picker"
/>
<input
value={color}
onChange={(e) => setColor(e.target.value)}
placeholder="#4f46e5"
className={inputClass + (color.trim() && !validColor ? " border-red-500/40" : "")}
/>
</div>
<p className="mt-1 text-xs text-white/30">Used for highlights in the tenant portal. Hex like #RRGGBB.</p>
</div>
<label className="flex cursor-pointer items-start gap-3 rounded-lg border border-white/[0.06] bg-white/[0.02] p-4">
<input
type="checkbox"
checked={hidePb}
onChange={(e) => setHidePb(e.target.checked)}
className="mt-0.5 h-4 w-4 accent-indigo-600"
/>
<span>
<span className="block text-sm font-medium text-white">Remove Powered by branding</span>
<span className="mt-0.5 block text-xs text-white/40">
Hides the Powered by Property Management Network line in the tenant portal.
</span>
</span>
</label>
<button
type="submit"
disabled={isPending || uploading}
className="w-full rounded-lg bg-indigo-600 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50"
>
{isPending ? "Saving..." : "Save Branding"}
</button>
</form>
{/* Live preview */}
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-white/30">Preview</p>
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="mb-4 flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl"
style={{ backgroundColor: previewColor }}
>
{logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoUrl} alt="Logo" className="h-full w-full object-contain p-1" />
) : (
<Home className="h-5 w-5 text-white" />
)}
</div>
<div>
<p className="text-xs text-white/40">{name.trim() || "Tenant Portal"}</p>
<h1 className="text-lg font-bold text-white">Jane Tenant</h1>
</div>
</div>
<div className="rounded-lg border border-white/[0.06] p-3">
<div className="flex items-center gap-2 text-sm" style={{ color: previewColor }}>
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: previewColor }} />
<span className="font-medium">Rent History</span>
</div>
</div>
{!hidePb && (
<p className="mt-4 text-center text-[10px] text-white/20">
Powered by Property Management Network
</p>
)}
</div>
</div>
</div>
)
}
+91 -13
View File
@@ -3,33 +3,111 @@
import { useState } from "react"
import { cn } from "@/lib/utils"
export function CheckoutButton({ plan, label, highlight }: { plan: string; label: string; highlight?: boolean }) {
export function CheckoutButton({
plan,
label,
highlight,
interval = "month",
annualAvailable = false,
paypalEnabled = false,
}: {
plan: string
label: string
highlight?: boolean
interval?: "month" | "year"
// When true, show a monthly/annual choice. Only pass this for subscription
// plans and only when annual billing is actually configured server-side.
annualAvailable?: boolean
// When true, also offer "Pay with PayPal" using the same interval choice.
paypalEnabled?: boolean
}) {
const [loading, setLoading] = useState(false)
const [paypalLoading, setPaypalLoading] = useState(false)
const [chosenInterval, setChosenInterval] = useState<"month" | "year">(interval)
const effectiveInterval = annualAvailable ? chosenInterval : interval
async function handleClick() {
setLoading(true)
const res = await fetch("/api/stripe/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan }),
body: JSON.stringify({ plan, interval: effectiveInterval }),
})
const data = await res.json()
if (data.url) window.location.href = data.url
else setLoading(false)
}
async function handlePaypal() {
setPaypalLoading(true)
const res = await fetch("/api/paypal/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan, interval: effectiveInterval }),
})
const data = await res.json()
if (data.url) window.location.href = data.url
else {
setPaypalLoading(false)
if (data.error) alert(data.error)
}
}
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"
<div className="space-y-2">
{annualAvailable && (
<div className="flex rounded-lg border border-white/10 p-0.5 text-[11px] font-medium">
<button
type="button"
onClick={() => setChosenInterval("month")}
className={cn(
"flex-1 rounded-md py-1 transition",
chosenInterval === "month" ? "bg-white/10 text-white" : "text-white/40 hover:text-white/70"
)}
>
Monthly
</button>
<button
type="button"
onClick={() => setChosenInterval("year")}
className={cn(
"flex-1 rounded-md py-1 transition",
chosenInterval === "year" ? "bg-white/10 text-white" : "text-white/40 hover:text-white/70"
)}
>
Annual
</button>
</div>
)}
>
{loading ? "Loading..." : label}
</button>
<button
onClick={handleClick}
disabled={loading || paypalLoading}
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>
{paypalEnabled && (
<button
onClick={handlePaypal}
disabled={loading || paypalLoading}
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-[#ffc439] py-2 text-xs font-bold text-[#003087] transition hover:bg-[#f0b90b] disabled:opacity-50"
>
{paypalLoading ? (
"Loading..."
) : (
<>
Pay with <span className="font-extrabold italic">Pay<span className="text-[#009cde]">Pal</span></span>
</>
)}
</button>
)}
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Trash2 } from "lucide-react"
import { toast } from "sonner"
import { ConfirmModal } from "@/components/ui/confirm-modal"
interface DeleteTenantButtonProps {
tenantId: string
tenantName?: string
/** When set, refresh in place instead of navigating to the tenants list. */
refreshOnly?: boolean
/** Compact icon-only variant for table rows. */
compact?: boolean
}
export function DeleteTenantButton({ tenantId, tenantName, refreshOnly, compact }: DeleteTenantButtonProps) {
const router = useRouter()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
async function handleDelete() {
setLoading(true)
try {
const res = await fetch(`/api/tenants/${tenantId}`, { method: "DELETE" })
if (!res.ok) {
const data = await res.json().catch(() => null)
toast.error(data?.error ?? "Failed to delete tenant")
return
}
toast.success(`${tenantName ?? "Tenant"} deleted`)
if (refreshOnly) {
router.refresh()
} else {
router.push("/tenants")
router.refresh()
}
} catch {
toast.error("Network error — please try again")
} finally {
setLoading(false)
setOpen(false)
}
}
return (
<>
{compact ? (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setOpen(true) }}
className="text-white/20 hover:text-red-400 transition"
title="Delete tenant"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={() => setOpen(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>
)}
<ConfirmModal
open={open}
title="Delete tenant?"
description={`Are you sure you want to delete ${tenantName ?? "this tenant"}? This frees their unit and cannot be undone.`}
confirmLabel="Delete"
loading={loading}
onConfirm={handleDelete}
onCancel={() => setOpen(false)}
/>
</>
)
}
+107
View File
@@ -0,0 +1,107 @@
"use client"
import { useTransition } from "react"
import { toast } from "sonner"
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle } from "lucide-react"
import { formatDate } from "@/lib/utils"
import { sendLeaseForSignatureAction } from "@/app/actions/esign"
type Req = {
id: string
provider: string
status: string
signer_email: string
document_name: string | null
sent_at: string | null
completed_at: string | null
last_error: string | null
}
type Prov = { id: string; label: string; configured: boolean }
const STATUS: Record<string, { label: string; cls: string; Icon: typeof Clock }> = {
sent: { label: "Awaiting signature", cls: "text-amber-400 bg-amber-500/10 border-amber-500/20", Icon: Clock },
signed: { label: "Signed", cls: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20", Icon: CheckCircle2 },
declined: { label: "Declined", cls: "text-red-400 bg-red-500/10 border-red-500/20", Icon: XCircle },
voided: { label: "Voided", cls: "text-white/40 bg-white/5 border-white/10", Icon: XCircle },
error: { label: "Failed", cls: "text-red-400 bg-red-500/10 border-red-500/20", Icon: AlertTriangle },
}
const PROVIDER_LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_sign: "Dropbox Sign" }
export function EsignLease({
leaseId,
providers,
requests,
canSend,
disabledReason,
}: {
leaseId: string
providers: Prov[]
requests: Req[]
canSend: boolean
disabledReason: string
}) {
const configured = providers.filter((p) => p.configured)
const [pending, start] = useTransition()
function send(provider: string) {
start(async () => {
try {
await sendLeaseForSignatureAction(leaseId, provider)
toast.success("Lease sent for signature")
} catch (e) {
toast.error((e as Error).message || "Couldn't send for signature")
}
})
}
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="mb-3 flex items-center gap-2">
<PenLine className="h-4 w-4 text-indigo-400" />
<h3 className="text-sm font-semibold text-white">E-signature</h3>
</div>
{requests.length > 0 && (
<ul className="mb-4 space-y-2">
{requests.map((r) => {
const s = STATUS[r.status] ?? STATUS.sent
return (
<li key={r.id} className="flex items-center justify-between gap-3 rounded-xl border border-white/[0.05] bg-white/[0.02] px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-xs font-medium text-white/80">{PROVIDER_LABEL[r.provider] ?? r.provider} · {r.signer_email}</p>
<p className="text-[11px] text-white/35">
Sent {r.sent_at ? formatDate(r.sent_at) : "—"}
{r.completed_at ? ` · Signed ${formatDate(r.completed_at)}` : ""}
{r.status === "error" && r.last_error ? ` · ${r.last_error}` : ""}
</p>
</div>
<span className={`flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${s.cls}`}>
<s.Icon className="h-3 w-3" /> {s.label}
</span>
</li>
)
})}
</ul>
)}
{configured.length === 0 ? (
<p className="text-xs text-white/30">Configure DocuSign or Dropbox Sign on the server to send leases for e-signature.</p>
) : canSend ? (
<div className="flex flex-wrap gap-2">
{configured.map((p) => (
<button
key={p.id}
onClick={() => send(p.id)}
disabled={pending}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50"
>
<PenLine className="h-3.5 w-3.5" /> Send via {p.label}
</button>
))}
</div>
) : (
<p className="text-xs text-white/30">{disabledReason}</p>
)}
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { toast } from "sonner"
import { Pencil, Ban, Trash2 } from "lucide-react"
import { ConfirmModal } from "@/components/ui/confirm-modal"
type Action = "terminate" | "delete" | null
export function LeaseActions({ lease }: { lease: any }) {
const router = useRouter()
const [pending, setPending] = useState<Action>(null)
const [loading, setLoading] = useState(false)
async function handleTerminate() {
setLoading(true)
try {
const res = await fetch(`/api/leases/${lease.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "terminated" }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
toast.error(typeof data?.error === "string" ? data.error : "Could not terminate lease")
return
}
toast.success("Lease terminated")
setPending(null)
router.refresh()
} catch {
toast.error("Something went wrong")
} finally {
setLoading(false)
}
}
async function handleDelete() {
setLoading(true)
try {
const res = await fetch(`/api/leases/${lease.id}`, { method: "DELETE" })
const data = await res.json().catch(() => ({}))
if (!res.ok) {
toast.error(typeof data?.error === "string" ? data.error : "Could not delete lease")
return
}
toast.success("Lease deleted")
setPending(null)
router.push("/leases")
router.refresh()
} catch {
toast.error("Something went wrong")
} finally {
setLoading(false)
}
}
return (
<>
<div className="flex items-center gap-2">
<Link
href={`/leases/${lease.id}/edit`}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-white/60 transition hover:border-white/20 hover:text-white"
>
<Pencil className="h-3.5 w-3.5" />
Edit
</Link>
{lease.status !== "terminated" && (
<button
onClick={() => setPending("terminate")}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-amber-400/80 transition hover:border-amber-500/30 hover:text-amber-400"
>
<Ban className="h-3.5 w-3.5" />
Terminate
</button>
)}
<button
onClick={() => setPending("delete")}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-white/40 transition hover:border-red-500/30 hover:text-red-400"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</div>
<ConfirmModal
open={pending === "terminate"}
variant="warning"
title="Terminate this lease?"
description="The lease will be marked as terminated. You can still view it afterwards."
confirmLabel="Terminate"
loading={loading}
onConfirm={handleTerminate}
onCancel={() => !loading && setPending(null)}
/>
<ConfirmModal
open={pending === "delete"}
variant="danger"
title="Delete this lease?"
description="This permanently removes the lease record. This action cannot be undone."
confirmLabel="Delete"
loading={loading}
onConfirm={handleDelete}
onCancel={() => !loading && setPending(null)}
/>
</>
)
}
+1 -1
View File
@@ -79,7 +79,7 @@ export function LeaseForm({ tenants, properties, lease, prefill }: {
setIsDirty(false)
toast.success(lease ? "Lease updated" : "Lease created")
router.push("/leases")
router.push(lease ? `/leases/${lease.id}` : "/leases")
router.refresh()
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { useState } from "react"
export function PaypalCancelButton() {
const [loading, setLoading] = useState(false)
async function handleClick() {
if (!confirm("Cancel your PayPal subscription? You'll keep access until the end of the current billing period.")) {
return
}
setLoading(true)
const res = await fetch("/api/paypal/cancel", { method: "POST" })
const data = await res.json().catch(() => ({}))
if (res.ok) {
window.location.href = "/settings/billing?canceled=true"
} else {
setLoading(false)
alert(data.error || "Could not cancel the subscription.")
}
}
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 ? "Canceling..." : "Cancel Subscription"}
</button>
)
}
+19
View File
@@ -31,6 +31,20 @@ export function RentActions({ payment }: { payment: any }) {
router.refresh()
}
async function sendLink() {
setLoading(true)
const res = await fetch("/api/rent/send-payment-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payment_id: payment.id }),
})
const data = await res.json().catch(() => ({}))
setLoading(false)
if (res.ok) toast.success(data.message ?? "Payment link emailed to tenant")
else toast.error(data.error ?? "Failed to send payment link")
router.refresh()
}
if (loading) return <span className="text-xs text-white/30">Updating...</span>
return (
@@ -40,6 +54,11 @@ export function RentActions({ payment }: { payment: any }) {
Mark Paid
</button>
)}
{payment.status !== "paid" && (
<button onClick={sendLink} className="text-xs text-indigo-400 hover:text-indigo-300">
Send link
</button>
)}
{payment.status !== "overdue" && payment.status !== "paid" && (
<button onClick={() => markAs("overdue")} className="text-xs text-red-400 hover:text-red-300">
Mark Overdue
+79
View File
@@ -0,0 +1,79 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Pencil, Trash2 } from "lucide-react"
import { toast } from "sonner"
interface UnitActionsProps {
propertyId: string
unitId: string
unitNumber: string
}
export function UnitActions({ propertyId, unitId, unitNumber }: UnitActionsProps) {
const router = useRouter()
const [confirming, setConfirming] = useState(false)
const [loading, setLoading] = useState(false)
async function handleDelete() {
setLoading(true)
try {
const res = await fetch(`/api/units/${unitId}`, { method: "DELETE" })
if (!res.ok) {
const data = await res.json().catch(() => null)
toast.error(data?.error ?? "Failed to delete unit")
return
}
toast.success(`Unit ${unitNumber} deleted`)
router.refresh()
} catch {
toast.error("Network error — please try again")
} finally {
setLoading(false)
setConfirming(false)
}
}
if (confirming) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-white/50">Delete?</span>
<button
onClick={handleDelete}
disabled={loading}
className="rounded-lg bg-red-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-red-500 disabled:opacity-50 transition"
>
{loading ? "Deleting…" : "Yes"}
</button>
<button
onClick={() => setConfirming(false)}
disabled={loading}
className="rounded-lg border border-white/10 px-2.5 py-1.5 text-xs text-white/60 hover:text-white transition"
>
Cancel
</button>
</div>
)
}
return (
<div className="flex items-center gap-1">
<Link
href={`/properties/${propertyId}/units/${unitId}/edit`}
className="rounded-lg border border-white/10 p-1.5 text-white/40 hover:border-white/20 hover:text-white transition"
title="Edit unit"
>
<Pencil className="h-3.5 w-3.5" />
</Link>
<button
onClick={() => setConfirming(true)}
className="rounded-lg border border-red-500/20 p-1.5 text-red-400/80 hover:border-red-500/40 hover:text-red-400 hover:bg-red-500/5 transition"
title="Delete unit"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
}
+24 -18
View File
@@ -4,6 +4,7 @@ import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Select } from "@/components/ui/select"
import type { Unit } from "@/types"
const statusOptions = [
{ value: "vacant", label: "Vacant" },
@@ -14,13 +15,15 @@ const statusOptions = [
interface UnitFormProps {
propertyId: string
unit?: Unit
onSuccess?: () => void
}
export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
export function UnitForm({ propertyId, unit, onSuccess }: UnitFormProps) {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [status, setStatus] = useState("vacant")
const [status, setStatus] = useState(unit?.status ?? "vacant")
const isEditing = Boolean(unit)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
@@ -38,21 +41,24 @@ export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
notes: formData.get("notes") || undefined,
}
const res = await fetch("/api/units", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
const res = await fetch(
isEditing ? `/api/units/${unit!.id}` : "/api/units",
{
method: isEditing ? "PATCH" : "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")
const data = await res.json().catch(() => null)
toast.error(data?.error ?? (isEditing ? "Failed to update unit" : "Failed to add unit"))
return
}
toast.success("Unit added")
toast.success(isEditing ? "Unit updated" : "Unit added")
if (onSuccess) {
onSuccess()
} else {
@@ -69,26 +75,26 @@ export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
<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} />
<input name="unit_number" required placeholder="e.g. 1A, 101" defaultValue={unit?.unit_number ?? ""} 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} />
<input name="rent_amount" type="number" required min={0} step={0.01} placeholder="1500" defaultValue={unit?.rent_amount ?? ""} 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} />
<input name="bedrooms" type="number" min={0} defaultValue={unit?.bedrooms ?? 1} className={inputClass} />
</div>
<div>
<label className={labelClass}>Bathrooms</label>
<input name="bathrooms" type="number" min={0} step={0.5} defaultValue={1} className={inputClass} />
<input name="bathrooms" type="number" min={0} step={0.5} defaultValue={unit?.bathrooms ?? 1} className={inputClass} />
</div>
<div>
<label className={labelClass}>Sq Ft</label>
<input name="sq_ft" type="number" min={0} placeholder="Optional" className={inputClass} />
<input name="sq_ft" type="number" min={0} placeholder="Optional" defaultValue={unit?.sq_ft ?? ""} className={inputClass} />
</div>
</div>
@@ -97,13 +103,13 @@ export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
<Select
options={statusOptions}
value={status}
onChange={setStatus}
onChange={(v) => setStatus(v as "vacant" | "occupied" | "maintenance" | "unavailable")}
/>
</div>
<div>
<label className={labelClass}>Notes</label>
<textarea name="notes" rows={3} placeholder="Optional notes..." className={`${inputClass} resize-none`} />
<textarea name="notes" rows={3} placeholder="Optional notes..." defaultValue={unit?.notes ?? ""} className={`${inputClass} resize-none`} />
</div>
<div className="flex gap-3 pt-2">
@@ -119,7 +125,7 @@ export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
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"}
{loading ? (isEditing ? "Saving…" : "Adding…") : isEditing ? "Save Changes" : "Add Unit"}
</button>
</div>
</form>
+36
View File
@@ -0,0 +1,36 @@
/* Leaflet map styling for the dark app shell. */
/* Our inline-SVG pin uses a divIcon; strip Leaflet's default white box. */
.leaflet-div-icon.pmn-map-pin {
background: transparent;
border: 0;
}
/* OSM tiles are light — tone them to match the dark UI. Only the tile pane is
filtered, so markers, popups and controls keep their real colors. */
.pmn-map-dark .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.95) contrast(0.9);
}
/* Popups on the dark surface. */
.pmn-map-dark .leaflet-popup-content-wrapper {
background: #16161f;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
}
.pmn-map-dark .leaflet-popup-tip {
background: #16161f;
}
.pmn-map-dark .leaflet-popup-content a {
color: #818cf8;
}
/* Attribution + controls readability on dark. */
.pmn-map-dark .leaflet-control-attribution {
background: rgba(9, 9, 11, 0.7);
color: rgba(255, 255, 255, 0.4);
}
.pmn-map-dark .leaflet-control-attribution a {
color: rgba(255, 255, 255, 0.6);
}
+103
View File
@@ -0,0 +1,103 @@
"use client"
import { useEffect, useRef } from "react"
import type * as Leaflet from "leaflet"
import "leaflet/dist/leaflet.css"
import "./property-map.css"
export type MapMarker = {
id: string
name: string
lat: number
lng: number
subtitle?: string
href?: string
}
// Indigo pin as an inline SVG divIcon — avoids Leaflet's default marker image
// assets (which break under bundlers) and matches the app accent.
const PIN_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="28" height="28" fill="#6366f1" stroke="#0b0b12" stroke-width="1.2"><path d="M12 0.5C6.9 0.5 2.8 4.6 2.8 9.7c0 6.6 8.2 13.9 8.6 14.2a0.9 0.9 0 0 0 1.2 0c0.4-0.3 8.6-7.6 8.6-14.2C21.2 4.6 17.1 0.5 12 0.5z"/><circle cx="12" cy="9.7" r="3.3" fill="#0b0b12"/></svg>`
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string
)
}
/**
* Leaflet map (OpenStreetMap tiles) rendering property markers. Client-only:
* Leaflet touches `window`, so it's dynamically imported inside an effect.
*/
export function PropertyMap({
markers,
className = "h-72 w-full",
zoom = 15,
}: {
markers: MapMarker[]
className?: string
zoom?: number
}) {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<Leaflet.Map | null>(null)
useEffect(() => {
let cancelled = false
void (async () => {
const mod = await import("leaflet")
const L = ((mod as unknown as { default?: typeof Leaflet }).default ?? mod) as typeof Leaflet
if (cancelled || !containerRef.current || mapRef.current) return
const map = L.map(containerRef.current, {
scrollWheelZoom: false,
zoomControl: true,
})
mapRef.current = map
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map)
const icon = L.divIcon({
html: PIN_SVG,
className: "pmn-map-pin",
iconSize: [28, 28],
iconAnchor: [14, 28],
popupAnchor: [0, -26],
})
const latlngs: [number, number][] = []
for (const m of markers) {
if (!Number.isFinite(m.lat) || !Number.isFinite(m.lng)) continue
const marker = L.marker([m.lat, m.lng], { icon }).addTo(map)
const link = m.href
? `<div style="margin-top:4px"><a href="${escapeHtml(m.href)}">View details →</a></div>`
: ""
const sub = m.subtitle ? `<div style="color:#9ca3af">${escapeHtml(m.subtitle)}</div>` : ""
marker.bindPopup(`<div style="font-weight:600">${escapeHtml(m.name)}</div>${sub}${link}`)
latlngs.push([m.lat, m.lng])
}
if (latlngs.length === 1) {
map.setView(latlngs[0], zoom)
} else if (latlngs.length > 1) {
map.fitBounds(latlngs, { padding: [40, 40] })
} else {
map.setView([39.8283, -98.5795], 4) // continental US fallback
}
// Tiles can render blank if the container sized after init.
setTimeout(() => map.invalidateSize(), 0)
})()
return () => {
cancelled = true
mapRef.current?.remove()
mapRef.current = null
}
}, [markers, zoom])
return <div ref={containerRef} className={`pmn-map-dark ${className}`} />
}
+1 -1
View File
@@ -27,7 +27,7 @@ const FAQS = [
},
{
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.",
a: "Yes. Every request is authenticated and scoped to your account, so 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?",
+1 -1
View File
@@ -72,7 +72,7 @@ const BENTO = [
{
icon: Shield,
title: "Secure by Default",
body: "Row-level security on every table. Tenants only see their own data — always.",
body: "Every request is authenticated and scoped to your account. 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",
+6 -15
View File
@@ -1,5 +1,7 @@
import Link from "next/link"
import { Building2, GitFork, Mail, ExternalLink } from "lucide-react"
import { GitFork, Mail, ExternalLink } from "lucide-react"
import { Logo } from "@/components/shared/logo"
import { LEGAL_PAGES } from "@/lib/legal"
const LINKS = {
Product: [
@@ -12,14 +14,8 @@ const LINKS = {
{ 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" },
],
Legal: LEGAL_PAGES.map((p) => ({ label: p.label, href: p.href })),
}
export function Footer() {
@@ -29,12 +25,7 @@ export function Footer() {
<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>
<Logo size="md" className="mb-4" />
<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>
@@ -71,7 +62,7 @@ export function Footer() {
<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>
<p>Built with Next.js · PostgreSQL · Stripe · SMTP2GO</p>
</div>
</div>
</footer>
+8 -8
View File
@@ -35,8 +35,8 @@ function WordReveal({ text, className = "", delay = 0 }: { text: string; classNa
{words.map((word, i) => (
<motion.span
key={i}
initial={{ opacity: 0, y: 16, filter: "blur(8px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
initial={{ opacity: 1, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: delay + i * 0.08, ease: "easeOut" }}
className="inline-block mr-[0.25em]"
>
@@ -75,7 +75,7 @@ const STATS = [
function DashboardMockup() {
return (
<motion.div
initial={{ opacity: 0, y: 40, rotateX: 8 }}
initial={{ opacity: 1, 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"
@@ -206,7 +206,7 @@ export function Hero() {
<div>
{/* Badge */}
<motion.div
initial={{ opacity: 0, y: 16 }}
initial={{ opacity: 1, 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"
@@ -229,7 +229,7 @@ export function Hero() {
</h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 1, 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"
@@ -239,7 +239,7 @@ export function Hero() {
{/* CTAs */}
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 1, 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"
@@ -264,7 +264,7 @@ export function Hero() {
{/* Trust signals */}
<motion.div
initial={{ opacity: 0 }}
initial={{ opacity: 1 }}
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"
@@ -279,7 +279,7 @@ export function Hero() {
{/* Animated stats */}
<motion.div
initial={{ opacity: 0, y: 10 }}
initial={{ opacity: 1, 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]"
+29 -71
View File
@@ -1,54 +1,39 @@
"use client"
import { motion } from "framer-motion"
import {
CreditCard, Mail, Cloud, Zap, BookOpen, MessageSquare,
BarChart3, Archive, FileText, Smartphone, Lock, RefreshCw,
} from "lucide-react"
import { CreditCard, LogIn } 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" },
// Only integrations that genuinely exist in the product are listed here.
// - Stripe: real payment processing (checkout + rent collection).
// - Google: real sign-in via OAuth (Better Auth Google provider).
// We deliberately do NOT advertise connectors we haven't built.
const TOOLS = [
{ name: "Stripe", icon: CreditCard, color: "text-violet-400", bg: "bg-violet-500/10 border-violet-500/20", desc: "Payments & rent collection" },
{ name: "Google", icon: LogIn, color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20", desc: "Sign in with Google (OAuth)" },
]
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]) {
function ToolBadge({ name, icon: Icon, color, bg, desc }: typeof TOOLS[0]) {
return (
<motion.div
whileHover={{ scale: 1.05, y: -2 }}
whileHover={{ scale: 1.03, 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`}
className={`flex items-center gap-3 rounded-xl border px-5 py-4 ${bg} cursor-default`}
>
<div className={`flex h-8 w-8 items-center justify-center rounded-lg bg-black/20`}>
<Icon className={`h-4 w-4 ${color}`} />
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-black/20">
<Icon className={`h-5 w-5 ${color}`} />
</div>
<div>
<p className="text-xs font-semibold text-white">{name}</p>
<p className="text-[10px] text-white/40">{desc}</p>
<p className="text-sm font-semibold text-white">{name}</p>
<p className="text-xs 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">
<div className="mx-auto max-w-3xl px-4 sm:px-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -56,50 +41,23 @@ export function Integrations() {
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>
<h2 className="text-3xl font-bold text-white sm:text-4xl">
Works with the 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.
Take rent payments with Stripe and let landlords sign in with Google no extra setup, no fragile connectors.
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="mt-10 grid gap-4 sm:grid-cols-2 max-w-xl mx-auto"
>
{TOOLS.map((t) => <ToolBadge key={t.name} {...t} />)}
</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>
)
}
+115
View File
@@ -0,0 +1,115 @@
import Link from "next/link"
import { LEGAL, LEGAL_PAGES } from "@/lib/legal"
/**
* Shared shell for every legal page. Renders a consistent header (title,
* effective/updated dates), the body, and a cross-link nav to sibling policies.
* `pt-32` clears the fixed marketing navbar.
*/
export function LegalPage({
title,
subtitle,
updated,
children,
}: {
title: string
subtitle?: string
/** Optional per-page override; defaults to the global LEGAL.lastUpdated. */
updated?: string
children: React.ReactNode
}) {
return (
<div className="bg-[#09090b] text-white min-h-screen">
<div className="mx-auto max-w-3xl px-6 pt-32 pb-24">
<nav className="mb-8 text-xs text-white/30">
<Link href="/" className="hover:text-white/60 transition">Home</Link>
<span className="mx-1.5">/</span>
<span className="text-white/50">{title}</span>
</nav>
<header className="mb-10 border-b border-white/[0.06] pb-8">
<h1 className="text-3xl font-bold text-white mb-3">{title}</h1>
{subtitle && <p className="text-sm text-white/50 leading-relaxed">{subtitle}</p>}
<p className="mt-4 text-xs text-white/30">
Effective date: {LEGAL.effectiveDate} · Last updated: {updated ?? LEGAL.lastUpdated}
</p>
</header>
<div className="space-y-9">{children}</div>
<PolicyNav current={title} />
</div>
</div>
)
}
/** A titled section. Pass plain text or JSX children (paragraphs, lists, etc.). */
export function Section({
id,
heading,
children,
}: {
id?: string
heading: string
children: React.ReactNode
}) {
return (
<section id={id} className="scroll-mt-32">
<h2 className="text-lg font-semibold text-white mb-3">{heading}</h2>
<div className="space-y-3 text-sm text-white/50 leading-relaxed [&_a]:text-indigo-300 [&_a:hover]:text-indigo-200 [&_strong]:text-white/80 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-1.5 [&_li]:marker:text-white/30">
{children}
</div>
</section>
)
}
/** Highlighted note (e.g. a disclaimer or important caveat). */
export function Callout({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/[0.06] px-4 py-3.5 text-sm text-amber-100/80 leading-relaxed">
{children}
</div>
)
}
/** Standard "questions / contact us" block for the bottom of a policy. */
export function LegalContact({
email = LEGAL.contactEmail,
children,
}: {
email?: string
children?: React.ReactNode
}) {
return (
<Section heading="Contact us">
<p>
{children ?? "If you have questions about this policy, contact us at "}
<a href={`mailto:${email}`}>{email}</a>.
</p>
<p className="text-white/30 text-xs">
{LEGAL.entity}
{LEGAL.address && !LEGAL.address.startsWith("[") ? ` · ${LEGAL.address}` : ""}
</p>
</Section>
)
}
/** Cross-links to the other legal documents. */
function PolicyNav({ current }: { current: string }) {
return (
<nav className="mt-14 border-t border-white/[0.06] pt-8">
<p className="text-xs font-semibold uppercase tracking-wider text-white/30 mb-4">
More policies
</p>
<ul className="grid grid-cols-2 gap-x-6 gap-y-2.5 sm:grid-cols-3">
{LEGAL_PAGES.filter((p) => p.label !== current).map((p) => (
<li key={p.href}>
<Link href={p.href} className="text-sm text-white/50 hover:text-white transition">
{p.label}
</Link>
</li>
))}
</ul>
</nav>
)
}
+3 -7
View File
@@ -3,7 +3,8 @@
import { useState, useEffect } from "react"
import Link from "next/link"
import { motion, AnimatePresence } from "framer-motion"
import { Building2, Menu, X, ArrowRight } from "lucide-react"
import { Menu, X, ArrowRight } from "lucide-react"
import { Logo } from "@/components/shared/logo"
const NAV_LINKS = [
{ label: "Features", href: "#features" },
@@ -35,12 +36,7 @@ export function Navbar() {
}`}
>
<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>
<Logo size="md" />
<div className="hidden md:flex items-center gap-1">
{NAV_LINKS.map((l) => (
+34 -29
View File
@@ -5,12 +5,13 @@ import Link from "next/link"
import { motion } from "framer-motion"
import { Check, X, Zap, ShieldCheck } from "lucide-react"
import { cn } from "@/lib/utils"
import { PLAN_AMOUNTS } from "@/lib/stripe/plans"
const PLANS = [
{
key: "starter",
name: "Starter",
monthlyPrice: 0,
monthlyPrice: PLAN_AMOUNTS.starter,
description: "For landlords just getting started.",
highlight: false,
cta: "Get started free",
@@ -20,7 +21,7 @@ const PLANS = [
{
key: "pro",
name: "Pro",
monthlyPrice: 29,
monthlyPrice: PLAN_AMOUNTS.pro,
description: "For active landlords growing their portfolio.",
highlight: true,
badge: "Most popular",
@@ -31,7 +32,7 @@ const PLANS = [
{
key: "landlord",
name: "Landlord",
monthlyPrice: 59,
monthlyPrice: PLAN_AMOUNTS.landlord,
description: "For serious portfolios at scale.",
highlight: false,
cta: "Start Landlord",
@@ -42,7 +43,7 @@ const PLANS = [
key: "lifetime",
name: "Lifetime",
monthlyPrice: null,
fixedPrice: 199,
fixedPrice: PLAN_AMOUNTS.lifetime,
description: "Pay once, own it forever.",
highlight: false,
badge: "Best value",
@@ -71,7 +72,7 @@ function Cell({ val }: { val: string | boolean }) {
return <span className="text-sm font-medium text-white/80">{val}</span>
}
export function PricingSection() {
export function PricingSection({ annualEnabled = false }: { annualEnabled?: boolean }) {
const [annual, setAnnual] = useState(false)
const DISCOUNT = 0.8
@@ -95,30 +96,34 @@ export function PricingSection() {
<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>
{/* Toggle — only shown when annual billing is actually purchasable.
When annual isn't configured we stay monthly-only rather than
advertising a plan that can't be bought. */}
{annualEnabled && (
<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 */}
-154
View File
@@ -1,154 +0,0 @@
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>
)
}
+108
View File
@@ -0,0 +1,108 @@
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
// Mirrors the visible FAQ content in components/marketing/faq.tsx.
// Keep these in sync with that source so the JSON-LD matches what users see.
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. Every request is authenticated and scoped to your account, so 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.",
},
]
const organization: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "Organization",
name: "Property Management Network",
url: base,
logo: `${base}/logo-mark.png`,
sameAs: [
"https://twitter.com/propertymgmtnet",
"https://github.com/propertymanagement-network",
],
}
const website: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "Property Management Network",
url: base,
}
const softwareApplication: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "Property Management Network",
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
description:
"Property management software for independent landlords — track rent, maintenance requests, leases and expenses in one place. Free to start.",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
}
const faqPage: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.q,
acceptedAnswer: {
"@type": "Answer",
text: faq.a,
},
})),
}
export function StructuredData() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organization) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(website) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareApplication) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqPage) }}
/>
</>
)
}
+41 -32
View File
@@ -1,64 +1,73 @@
import Link from "next/link"
import { Building2 } from "lucide-react"
import Image from "next/image"
import { cn } from "@/lib/utils"
type Size = "sm" | "md" | "lg"
type Theme = "light" | "dark"
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" },
// Wordmark lockup height per size — width follows the image's aspect ratio.
const WORDMARK_H: Record<Size, string> = {
sm: "h-6", // 24px
md: "h-7", // 28px
lg: "h-8", // 32px
}
/** The brand mark — gradient rounded square with the building glyph. */
// Square brand-mark box per size.
const MARK_BOX: Record<Size, string> = {
sm: "h-7 w-7",
md: "h-8 w-8",
lg: "h-9 w-9",
}
/** The square brand mark (framework "monster") — used where space is tight, e.g. the collapsed sidebar. */
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>
<Image
src="/logo-mark.png"
alt="Property Management Network"
width={512}
height={512}
priority
className={cn("shrink-0 rounded-lg object-contain", MARK_BOX[size], className)}
/>
)
}
/**
* Full brand lockup: mark + "Property Management" / "Network" wordmark.
* Designed for the app's dark surfaces (white title, indigo accent).
* Full brand wordmark lockup (mark + "Property Management Network").
* `theme="light"` (default) renders the white wordmark for dark surfaces;
* `theme="dark"` renders the dark wordmark for light surfaces.
* Pass `href={null}` to render a non-link version.
*/
export function Logo({
size = "md",
theme = "light",
className,
href = "/",
}: {
size?: Size
theme?: Theme
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 img = (
<Image
src={theme === "dark" ? "/logo-dark.png" : "/logo-light.png"}
alt="Property Management Network"
width={375}
height={40}
priority
className={cn("w-auto", WORDMARK_H[size])}
/>
)
const classes = cn("flex items-center", s.gap, className)
const classes = cn("flex items-center", className)
return href ? (
<Link href={href} className={classes}>
{content}
<Link href={href} className={classes} aria-label="Property Management Network">
{img}
</Link>
) : (
<div className={classes}>{content}</div>
<div className={classes}>{img}</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { Logo } from "@/components/shared/logo"
/**
* Full-screen "we'll be right back" notice rendered in place of the app when
* site maintenance mode is on (for everyone except admins).
*/
export function MaintenanceScreen({ message }: { message?: string | null }) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 bg-[#09090b] px-6 text-center text-white">
<Logo size="lg" href={null} />
<div className="max-w-md">
<h1 className="text-3xl font-bold tracking-tight">We&apos;ll be right back</h1>
<p className="mt-3 text-white/60 leading-relaxed">
{message ||
"The site is temporarily offline for scheduled maintenance. Please check back shortly — thanks for your patience."}
</p>
</div>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
"use client"
import { useEffect, useRef } from "react"
import { cn } from "@/lib/utils"
declare global {
interface Window {
turnstile?: {
render: (el: HTMLElement, opts: Record<string, unknown>) => string
remove: (id: string) => void
reset: (id?: string) => void
}
}
}
const SCRIPT_ID = "cf-turnstile-script"
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
/**
* Cloudflare Turnstile widget (explicit render).
*
* Renders inside the enclosing <form>; on success Turnstile injects a hidden
* <input name="cf-turnstile-response"> that is submitted with the form and
* verified server-side by verifyTurnstile(). Renders nothing when the public
* site key is not configured, so the forms still work without Turnstile.
*/
export function TurnstileWidget({ className }: { className?: string }) {
const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY
const containerRef = useRef<HTMLDivElement>(null)
const widgetIdRef = useRef<string | null>(null)
useEffect(() => {
if (!siteKey) return
let cancelled = false
const render = () => {
if (cancelled || widgetIdRef.current || !containerRef.current || !window.turnstile) return
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
theme: "dark",
})
}
if (!document.getElementById(SCRIPT_ID)) {
const script = document.createElement("script")
script.id = SCRIPT_ID
script.src = SCRIPT_SRC
script.async = true
script.defer = true
script.onload = render
document.head.appendChild(script)
} else {
render()
}
// Fallback: the script may already be cached/loaded so onload won't fire.
const poll = window.setInterval(() => {
if (window.turnstile) {
render()
window.clearInterval(poll)
}
}, 150)
return () => {
cancelled = true
window.clearInterval(poll)
if (widgetIdRef.current && window.turnstile) {
try {
window.turnstile.remove(widgetIdRef.current)
} catch {
// widget already removed
}
widgetIdRef.current = null
}
}
}, [siteKey])
if (!siteKey) return null
return <div ref={containerRef} className={cn("min-h-[65px]", className)} />
}
+30
View File
@@ -0,0 +1,30 @@
"use client"
import { Toaster as SonnerToaster } from "sonner"
import { CheckCircle2, XCircle, AlertTriangle, Info, Loader2 } from "lucide-react"
/**
* App-wide toast surface. Styling lives in globals.css (targeting Sonner's
* data-attributes) so the toast reads as part of the product's dark design
* system; here we only set behavior and the brand-colored type icons.
*/
export function Toaster() {
return (
<SonnerToaster
position="bottom-right"
theme="dark"
closeButton
expand
gap={12}
offset={20}
duration={4000}
icons={{
success: <CheckCircle2 className="h-[18px] w-[18px] text-emerald-400" />,
error: <XCircle className="h-[18px] w-[18px] text-red-400" />,
warning: <AlertTriangle className="h-[18px] w-[18px] text-amber-400" />,
info: <Info className="h-[18px] w-[18px] text-indigo-400" />,
loading: <Loader2 className="h-[18px] w-[18px] animate-spin text-indigo-400" />,
}}
/>
)
}