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
@@ -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) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user