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:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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'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>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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:
|
||||
'© <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}`} />
|
||||
}
|
||||
@@ -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?",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 +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) => (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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)} />
|
||||
}
|
||||
@@ -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" />,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user