Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes

Deploy config:
- .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead
  of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the
  propertymanagement.network domain so they bake into the client bundle; add
  custom domains block (apex + www); wire Sentry DSN (server + browser).

Included pending work from the audit-fixes branch:
- AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default)
- Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010
- Outbound webhooks / Zapier integration
- PayPal removal (Stripe-only billing)
- Storage hardening (fail-loud when Spaces unconfigured), security fixes

Verified: full production Docker build (same build-args as DO) passes clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-03 04:45:24 -04:00
co-authored by Claude Opus 4.8
parent 917a06ee85
commit 5495b94924
86 changed files with 7647 additions and 1182 deletions
+119
View File
@@ -0,0 +1,119 @@
"use client"
import { useState, useTransition } from "react"
import { toast } from "sonner"
import { Sparkles, Check, AlertTriangle } from "lucide-react"
import { setAiProviderAction } from "@/app/actions/admin"
type Provider = "openai" | "anthropic"
const LABELS: Record<Provider, string> = { openai: "OpenAI", anthropic: "Anthropic (Claude)" }
export function AiProviderToggle({
selected,
effective,
openaiConfigured,
anthropicConfigured,
openaiModel,
anthropicModel,
}: {
selected: Provider
effective: Provider
openaiConfigured: boolean
anthropicConfigured: boolean
openaiModel: string
anthropicModel: string
}) {
const [current, setCurrent] = useState<Provider>(selected)
const [pending, startTransition] = useTransition()
const configured: Record<Provider, boolean> = { openai: openaiConfigured, anthropic: anthropicConfigured }
const models: Record<Provider, string> = { openai: openaiModel, anthropic: anthropicModel }
function choose(next: Provider) {
if (next === current || pending) return
const prev = current
setCurrent(next)
startTransition(async () => {
try {
await setAiProviderAction(next)
toast.success(`AI provider set to ${LABELS[next]}`)
} catch {
setCurrent(prev) // revert optimistic change
toast.error("Couldn't switch the AI provider. Try again.")
}
})
}
// When the selected provider has no key on the server, AI falls back to the
// other configured provider (see lib/ai/provider). Surface that clearly.
const fallbackActive = effective !== current
const noneConfigured = !openaiConfigured && !anthropicConfigured
const options: Provider[] = ["openai", "anthropic"]
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">
<Sparkles className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">AI provider</h2>
</div>
<div className="px-5 py-4 space-y-3">
<p className="text-xs text-white/40">
Choose which LLM powers all AI features (assistant, recommendations, predictions, summaries,
receipts). Applies to everyone immediately.
</p>
<div className="grid gap-2 sm:grid-cols-2">
{options.map((p) => {
const active = current === p
return (
<button
key={p}
type="button"
onClick={() => choose(p)}
disabled={pending}
aria-pressed={active}
className={`flex items-start justify-between gap-3 rounded-xl border px-4 py-3 text-left transition disabled:opacity-60 ${
active
? "border-rose-500/40 bg-rose-500/[0.08]"
: "border-white/10 bg-white/[0.02] hover:bg-white/[0.05]"
}`}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white">{LABELS[p]}</span>
{active && <Check className="h-3.5 w-3.5 text-rose-400" />}
</div>
<p className="mt-0.5 font-mono text-[11px] text-white/40 truncate">{models[p]}</p>
<p className="mt-1 text-[11px]">
{configured[p] ? (
<span className="text-emerald-400">API key configured</span>
) : (
<span className="text-amber-400">No API key on server</span>
)}
</p>
</div>
</button>
)
})}
</div>
{noneConfigured ? (
<p className="flex items-start gap-1.5 rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-300/90">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
No AI provider key is set on the server AI features return a 503 until{" "}
<code className="font-mono">OPENAI_API_KEY</code> or <code className="font-mono">ANTHROPIC_API_KEY</code> is configured.
</p>
) : fallbackActive ? (
<p className="flex items-start gap-1.5 rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-300/90">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
{LABELS[current]} has no API key on this server, so AI is temporarily running on{" "}
<span className="font-semibold">{LABELS[effective]}</span>. Add the key to use {LABELS[current]}.
</p>
) : null}
</div>
</div>
)
}
+268
View File
@@ -0,0 +1,268 @@
"use client"
import { useEffect, useState, useTransition } from "react"
import { toast } from "sonner"
import {
PenLine,
Link2,
CheckCircle2,
AlertTriangle,
KeyRound,
ChevronDown,
ExternalLink,
Loader2,
} from "lucide-react"
import { connectDropboxSign, disconnectEsignAction } from "@/app/actions/esign"
type Adapter = { id: string; label: string; kind: "oauth" | "apikey"; available: boolean }
type Conn = { provider: string; accountName: string | null; status: string; lastError: string | null }
const ERR_MSG: Record<string, string> = {
connect_failed: "Connection failed — please try again.",
invalid_state: "The connection link expired or was invalid. Please retry.",
owner_only: "Only the account owner can manage integrations.",
not_configured: "E-signature isn't enabled on this server yet.",
unknown_provider: "Unknown provider.",
use_api_key: "That provider connects with an API key, not a redirect.",
}
const LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_sign: "Dropbox Sign" }
function StatusPill({ status }: { status: string }) {
const error = status === "error"
return (
<span
className={`flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium ${
error ? "border-red-500/20 bg-red-500/10 text-red-400" : "border-emerald-500/20 bg-emerald-500/10 text-emerald-400"
}`}
>
{error ? <AlertTriangle className="h-3 w-3" /> : <CheckCircle2 className="h-3 w-3" />}
{error ? "Error" : "Connected"}
</span>
)
}
export function EsignIntegrations({
adapters,
connections,
isOwner,
flash,
webhookUrl,
}: {
adapters: Adapter[]
connections: Conn[]
isOwner: boolean
flash: { connected?: string; error?: string }
webhookUrl: 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)
const [open, setOpen] = useState<string | null>(null) // which provider's instructions are expanded
const [apiKey, setApiKey] = useState("")
const [showKeyForm, setShowKeyForm] = useState(false)
useEffect(() => {
if (flash.connected) toast.success(`Connected to ${LABEL[flash.connected] ?? flash.connected}`)
if (flash.error) toast.error(ERR_MSG[flash.error] ?? "Something went wrong")
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function connectDbx() {
const key = apiKey.trim()
if (!key) return
setBusy("dropbox_sign")
start(async () => {
try {
const r = await connectDropboxSign(key)
toast.success(`Connected ${r.accountName ?? "Dropbox Sign"}`)
setApiKey("")
setShowKeyForm(false)
} catch (e) {
toast.error((e as Error).message || "Couldn't connect")
} finally {
setBusy(null)
}
})
}
function remove(id: string) {
if (!confirm(`Disconnect ${LABEL[id] ?? id}? You won't be able to send leases through it until you reconnect.`)) return
setBusy(id)
start(async () => {
try {
await disconnectEsignAction(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 connect e-signature providers.
</div>
)
}
return (
<div className="space-y-3">
{/* Intro / how it works */}
<div className="rounded-2xl border border-indigo-500/15 bg-indigo-500/[0.04] p-5">
<div className="flex items-center gap-2">
<PenLine className="h-4 w-4 text-indigo-400" />
<h3 className="text-sm font-semibold text-white">Send leases for e-signature</h3>
</div>
<p className="mt-1.5 text-xs leading-relaxed text-white/50">
Connect <span className="text-white/80">your own</span> DocuSign or Dropbox Sign account so signed leases carry
your brand and audit trail and the signing costs stay on your provider plan, not ours. Once connected, a
<span className="text-white/80"> Send for signature </span> button appears on every lease that has a document
and a tenant email.
</p>
<ol className="mt-3 space-y-1.5 text-xs text-white/50">
<li>1. Connect your provider below (one-time).</li>
<li>2. Open a lease upload the lease PDF.</li>
<li>3. Click Send via DocuSign / Dropbox Sign. The tenant signs; the status updates here automatically and the signed copy is saved back to the lease.</li>
</ol>
</div>
{adapters.map((a) => {
const conn = connByProvider[a.id]
const isBusy = pending && busy === a.id
const instructionsOpen = open === a.id
return (
<div key={a.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">
<PenLine className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold text-white">{a.label}</p>
{conn ? (
<p className="text-xs text-white/40">{conn.accountName ?? "Connected"}</p>
) : (
<p className="text-xs text-white/40">
{a.kind === "oauth" ? "Connect with your DocuSign login" : "Connect with your API key"}
</p>
)}
</div>
</div>
{conn ? (
<StatusPill status={conn.status} />
) : !a.available ? (
<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 available
</span>
) : null}
</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>
)}
{/* Actions */}
<div className="mt-4 flex flex-wrap items-center gap-2">
{conn ? (
<button
onClick={() => remove(a.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"
>
{isBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Disconnect"}
</button>
) : !a.available ? (
<p className="text-xs text-white/30">
Ask your administrator to enable {a.label} (server credentials aren&apos;t configured).
</p>
) : a.kind === "oauth" ? (
<a
href={`/api/esign/${a.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 {a.label}
</a>
) : showKeyForm ? (
<div className="flex w-full flex-col gap-2 sm:flex-row">
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Paste your Dropbox Sign API key"
className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-white placeholder-white/30 outline-none focus:border-indigo-500/50"
/>
<button
onClick={connectDbx}
disabled={isBusy || !apiKey.trim()}
className="flex items-center justify-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50"
>
{isBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <KeyRound className="h-3.5 w-3.5" />} Connect
</button>
</div>
) : (
<button
onClick={() => setShowKeyForm(true)}
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"
>
<KeyRound className="h-3.5 w-3.5" /> Connect {a.label}
</button>
)}
{a.available && (
<button
onClick={() => setOpen(instructionsOpen ? null : a.id)}
className="ml-auto flex items-center gap-1 text-[11px] text-white/40 transition hover:text-white/70"
>
How to connect <ChevronDown className={`h-3 w-3 transition ${instructionsOpen ? "rotate-180" : ""}`} />
</button>
)}
</div>
{/* Instructions */}
{instructionsOpen && (
<div className="mt-3 rounded-lg border border-white/[0.06] bg-white/[0.02] p-4 text-xs leading-relaxed text-white/55">
{a.id === "docusign" ? (
<ol className="space-y-1.5">
<li>
1. You need an active{" "}
<a href="https://www.docusign.com/products/electronic-signature" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 text-indigo-400 hover:text-indigo-300">
DocuSign eSignature plan <ExternalLink className="h-3 w-3" />
</a>.
</li>
<li>2. Click <span className="text-white/80">Connect DocuSign</span> above.</li>
<li>3. Log in to <span className="text-white/80">your</span> DocuSign account and click <span className="text-white/80">Allow</span> to grant access.</li>
<li>4. You&apos;ll return here connected no webhook setup needed. Signed-status updates and the completed PDF flow back automatically.</li>
</ol>
) : (
<ol className="space-y-1.5">
<li>
1. In Dropbox Sign, open{" "}
<a href="https://app.hellosign.com/account/settings/api" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 text-indigo-400 hover:text-indigo-300">
Settings API <ExternalLink className="h-3 w-3" />
</a>{" "}
and copy your <span className="text-white/80">API key</span>.
</li>
<li>2. Paste it above and click <span className="text-white/80">Connect</span>.</li>
<li>
3. In the same API settings, set your <span className="text-white/80">account callback URL</span> to:
<code className="mt-1 block overflow-x-auto rounded bg-black/30 px-2 py-1 font-mono text-[11px] text-emerald-300">{webhookUrl}</code>
This lets us receive signed-status updates.
</li>
</ol>
)}
</div>
)}
</div>
)
})}
<p className="px-1 text-[11px] text-white/30">
Your credentials are encrypted at rest and never leave the server. We only send the leases you explicitly submit.
</p>
</div>
)
}
+1 -36
View File
@@ -9,7 +9,6 @@ export function CheckoutButton({
highlight,
interval = "month",
annualAvailable = false,
paypalEnabled = false,
}: {
plan: string
label: string
@@ -18,11 +17,8 @@ export function CheckoutButton({
// 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
@@ -39,21 +35,6 @@ export function CheckoutButton({
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 (
<div className="space-y-2">
{annualAvailable && (
@@ -82,7 +63,7 @@ export function CheckoutButton({
)}
<button
onClick={handleClick}
disabled={loading || paypalLoading}
disabled={loading}
className={cn(
"w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50",
highlight
@@ -92,22 +73,6 @@ export function CheckoutButton({
>
{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>
)
}
+24 -8
View File
@@ -1,8 +1,9 @@
"use client"
import { useTransition } from "react"
import Link from "next/link"
import { toast } from "sonner"
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle } from "lucide-react"
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle, Download } from "lucide-react"
import { formatDate } from "@/lib/utils"
import { sendLeaseForSignatureAction } from "@/app/actions/esign"
@@ -12,11 +13,12 @@ type Req = {
status: string
signer_email: string
document_name: string | null
signed_document_url: string | null
sent_at: string | null
completed_at: string | null
last_error: string | null
}
type Prov = { id: string; label: string; configured: boolean }
type Prov = { id: string; label: string }
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 },
@@ -29,18 +31,17 @@ const PROVIDER_LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_s
export function EsignLease({
leaseId,
providers,
connected,
requests,
canSend,
disabledReason,
}: {
leaseId: string
providers: Prov[]
connected: Prov[]
requests: Req[]
canSend: boolean
disabledReason: string
}) {
const configured = providers.filter((p) => p.configured)
const [pending, start] = useTransition()
function send(provider: string) {
@@ -74,6 +75,16 @@ export function EsignLease({
{r.completed_at ? ` · Signed ${formatDate(r.completed_at)}` : ""}
{r.status === "error" && r.last_error ? ` · ${r.last_error}` : ""}
</p>
{r.signed_document_url && (
<a
href={r.signed_document_url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-[11px] text-indigo-400 transition hover:text-indigo-300"
>
<Download className="h-3 w-3" /> Signed document
</a>
)}
</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}
@@ -84,11 +95,16 @@ export function EsignLease({
</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>
{connected.length === 0 ? (
<p className="text-xs text-white/30">
<Link href="/settings/integrations" className="text-indigo-400 hover:text-indigo-300">
Connect DocuSign or Dropbox Sign
</Link>{" "}
in Settings Integrations to send leases for signature.
</p>
) : canSend ? (
<div className="flex flex-wrap gap-2">
{configured.map((p) => (
{connected.map((p) => (
<button
key={p.id}
onClick={() => send(p.id)}
+99
View File
@@ -0,0 +1,99 @@
"use client"
import { useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { FileText, Upload, ExternalLink, Loader2 } from "lucide-react"
import { setLeaseDocument } from "@/app/actions/esign"
export function LeaseDocument({
leaseId,
documentUrl,
canWrite,
}: {
leaseId: string
documentUrl: string | null
canWrite: boolean
}) {
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (!/\.(pdf|docx?)$/i.test(file.name)) {
toast.error("Upload a PDF or Word document")
if (inputRef.current) inputRef.current.value = ""
return
}
setUploading(true)
try {
const fd = new FormData()
fd.append("file", file)
fd.append("scope", "documents")
const res = await fetch("/api/upload", { method: "POST", body: fd })
if (!res.ok) {
const j = await res.json().catch(() => ({}))
throw new Error(j?.error || "Upload failed")
}
const { url } = (await res.json()) as { url: string }
await setLeaseDocument(leaseId, url)
toast.success(documentUrl ? "Lease document replaced" : "Lease document attached")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed")
} finally {
setUploading(false)
if (inputRef.current) inputRef.current.value = ""
}
}
return (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium text-white">
<FileText className="h-4 w-4 shrink-0 text-indigo-400" />
{documentUrl ? "Lease document" : "No lease document yet"}
</div>
{documentUrl && (
<a
href={documentUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-indigo-400 transition hover:text-indigo-300"
>
View <ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
{!documentUrl && (
<p className="mt-1.5 text-xs text-white/40">Upload the lease PDF to enable sending it for e-signature.</p>
)}
{canWrite && (
<div className="mt-3">
<input
ref={inputRef}
type="file"
accept=".pdf,.doc,.docx"
onChange={onPick}
disabled={uploading}
className="hidden"
id={`lease-doc-${leaseId}`}
/>
<label
htmlFor={`lease-doc-${leaseId}`}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 transition hover:bg-white/[0.06] hover:text-white ${
uploading ? "pointer-events-none opacity-50" : ""
}`}
>
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
{documentUrl ? "Replace document" : "Upload lease document"}
</label>
</div>
)}
</div>
)
}
-32
View File
@@ -1,32 +0,0 @@
"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>
)
}
+20 -5
View File
@@ -1,5 +1,19 @@
import { PLAN_AMOUNTS, getPlanLabel } from "@/lib/stripe/plans"
import type { Plan } from "@/types"
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
// Real plans + displayed prices from lib/stripe/plans.ts (single source of truth).
// Annual billing is auto-provisioned at checkout and has no fixed amount here, so
// we advertise only the monthly / one-time base prices that actually exist.
const planOrder: Plan[] = ["starter", "pro", "landlord", "lifetime"]
const planOffers = planOrder.map((plan) => ({
"@type": "Offer",
name: getPlanLabel(plan),
price: String(PLAN_AMOUNTS[plan]),
priceCurrency: "USD",
}))
// 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 = [
@@ -43,6 +57,11 @@ const organization: Record<string, unknown> = {
name: "Property Management Network",
url: base,
logo: `${base}/logo-mark.png`,
contactPoint: {
"@type": "ContactPoint",
contactType: "customer support",
email: "support@propertymanagement.network",
},
sameAs: [
"https://twitter.com/propertymgmtnet",
"https://github.com/propertymanagement-network",
@@ -64,11 +83,7 @@ const softwareApplication: Record<string, unknown> = {
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",
},
offers: planOffers,
}
const faqPage: Record<string, unknown> = {