Files
Leon SerfatyandClaude Opus 4.8 c9968531e4 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>
2026-07-02 13:42:34 -04:00

279 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState } from "react"
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: PLAN_AMOUNTS.starter,
description: "For landlords just getting started.",
highlight: false,
cta: "Get started free",
ctaHref: "/signup",
features: ["1 property", "Up to 3 tenants", "100 MB storage", "Rent tracking", "Maintenance portal", "Lease tracking", "Tenant portal"],
},
{
key: "pro",
name: "Pro",
monthlyPrice: PLAN_AMOUNTS.pro,
description: "For active landlords growing their portfolio.",
highlight: true,
badge: "Most popular",
cta: "Start Pro",
ctaHref: "/signup",
features: ["10 properties", "Unlimited tenants", "5 GB storage", "50 AI calls/mo", "Email reminders", "Stripe payment links", "Lease expiry alerts", "CSV export"],
},
{
key: "landlord",
name: "Landlord",
monthlyPrice: PLAN_AMOUNTS.landlord,
description: "For serious portfolios at scale.",
highlight: false,
cta: "Start Landlord",
ctaHref: "/signup",
features: ["Unlimited properties", "Unlimited tenants", "25 GB storage", "200 AI calls/mo", "Team access", "White-label portal", "Priority support"],
},
{
key: "lifetime",
name: "Lifetime",
monthlyPrice: null,
fixedPrice: PLAN_AMOUNTS.lifetime,
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", "White-label portal", "Team access", "25 GB storage"],
},
]
const COMPARISON = [
{ feature: "Properties", starter: "1", pro: "10", landlord: "Unlimited", lifetime: "Unlimited" },
{ feature: "Tenants", starter: "3", pro: "Unlimited", landlord: "Unlimited", lifetime: "Unlimited" },
{ feature: "Storage", starter: "100 MB", pro: "5 GB", landlord: "25 GB", lifetime: "25 GB" },
{ feature: "AI calls / month", starter: false, pro: "50", landlord: "200", lifetime: "200" },
{ feature: "Email reminders", starter: false, pro: true, landlord: true, lifetime: true },
{ feature: "Stripe payment links", starter: false, pro: true, landlord: true, lifetime: true },
{ feature: "Team access", starter: false, pro: false, landlord: true, lifetime: true },
{ feature: "White-label portal", starter: false, pro: false, landlord: true, lifetime: true },
{ feature: "CSV export", starter: true, pro: true, landlord: true, lifetime: true },
{ feature: "Lease expiry alerts", starter: true, pro: true, landlord: true, lifetime: true },
]
function Cell({ val }: { val: string | boolean }) {
if (val === true) return <Check className="mx-auto h-4 w-4 text-emerald-400" />
if (val === false) return <X className="mx-auto h-4 w-4 text-white/20" />
return <span className="text-sm font-medium text-white/80">{val}</span>
}
export function PricingSection({ annualEnabled = false }: { annualEnabled?: boolean }) {
const [annual, setAnnual] = useState(false)
const DISCOUNT = 0.8
return (
<section id="pricing" className="relative py-24 overflow-hidden">
{/* Section background */}
<div className="absolute inset-0 -z-10 bg-gradient-to-b from-transparent via-indigo-950/20 to-transparent" />
<div className="absolute inset-0 -z-10" style={{
backgroundImage: "radial-gradient(ellipse 80% 50% at 50% 0%, rgba(99,102,241,0.08) 0%, transparent 70%)"
}} />
<div className="mx-auto max-w-6xl px-4 sm:px-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="mb-12 text-center"
>
<p className="mb-3 text-xs 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="mt-3 text-white/50">Start free, upgrade when you grow. No per-unit fees, ever.</p>
{/* 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 */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 items-start">
{PLANS.map((plan, i) => {
const isLifetime = plan.fixedPrice !== undefined
const displayPrice = isLifetime
? `$${plan.fixedPrice}`
: plan.monthlyPrice === 0
? "Free"
: annual
? `$${Math.round(plan.monthlyPrice! * DISCOUNT * 12)}`
: `$${plan.monthlyPrice}`
const interval = isLifetime
? " one-time"
: plan.monthlyPrice === 0
? ""
: annual ? "/yr" : "/mo"
return (
<motion.div
key={plan.key}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08 }}
className={cn(
"relative flex flex-col rounded-2xl p-6 transition-all duration-300",
plan.highlight
? "border-2 border-indigo-500/60 bg-gradient-to-b from-indigo-600/15 to-[#16161f] shadow-2xl shadow-indigo-500/20 ring-1 ring-indigo-500/20"
: "border border-white/10 bg-[#16161f] hover:border-white/20"
)}
>
{plan.badge && (
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 z-10">
<span className={cn(
"flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold shadow-lg",
plan.highlight
? "bg-indigo-600 text-white shadow-indigo-500/40"
: "bg-[#1d1d28] border border-white/10 text-white/70"
)}>
<Zap className="h-3 w-3" />
{plan.badge}
</span>
</div>
)}
{/* Plan name + price */}
<div className="mb-6 pt-1">
<p className={cn("text-xs font-semibold uppercase tracking-wide mb-2", plan.highlight ? "text-indigo-400" : "text-white/40")}>
{plan.name}
</p>
<div className="flex items-baseline gap-1">
<span className="text-3xl sm:text-4xl font-extrabold text-white tracking-tight">{displayPrice}</span>
{interval && <span className="text-sm text-white/40 ml-0.5">{interval}</span>}
</div>
{annual && !isLifetime && plan.monthlyPrice! > 0 && (
<p className="mt-1 text-xs text-emerald-400 font-medium">
Saves ${Math.round(plan.monthlyPrice! * 12 * 0.2)}/year
</p>
)}
<p className="mt-2 text-xs text-white/40 leading-relaxed">{plan.description}</p>
</div>
{/* CTA */}
<Link
href={plan.ctaHref}
className={cn(
"mb-6 block rounded-xl px-4 py-2.5 text-center text-sm font-semibold transition-all",
plan.highlight
? "bg-indigo-600 text-white hover:bg-indigo-500 hover:shadow-lg hover:shadow-indigo-500/30"
: "border border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white hover:border-white/20"
)}
>
{plan.cta}
</Link>
{/* Features */}
<ul className="flex-1 space-y-2.5">
{plan.features.map((f) => (
<li key={f} className="flex items-start gap-2.5">
<Check className={cn("mt-0.5 h-3.5 w-3.5 flex-shrink-0", plan.highlight ? "text-indigo-400" : "text-white/40")} />
<span className="text-xs text-white/60 leading-relaxed">{f}</span>
</li>
))}
</ul>
</motion.div>
)
})}
</div>
{/* Money-back guarantee */}
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="mt-8 flex items-center justify-center gap-2 text-xs text-white/40"
>
<ShieldCheck className="h-4 w-4 text-emerald-400" />
<span>30-day money-back guarantee on all paid plans. No questions asked.</span>
</motion.div>
{/* Comparison table */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.2 }}
className="mt-16"
>
<h3 className="mb-6 text-center text-lg font-semibold text-white">Full feature comparison</h3>
<div className="overflow-x-auto rounded-2xl border border-white/[0.08] bg-[#16161f]">
<table className="w-full min-w-[600px]">
<thead>
<tr className="border-b border-white/[0.08]">
<th className="px-5 py-4 text-left text-xs font-medium text-white/40 w-[40%]">Feature</th>
{[
{ name: "Starter", highlight: false },
{ name: "Pro", highlight: true },
{ name: "Landlord", highlight: false },
{ name: "Lifetime", highlight: false },
].map((h) => (
<th key={h.name} className={cn(
"px-4 py-4 text-center text-xs font-semibold",
h.highlight ? "text-indigo-400" : "text-white/60"
)}>
{h.name}
</th>
))}
</tr>
</thead>
<tbody>
{COMPARISON.map((row, i) => (
<tr key={row.feature} className={cn(
"border-b border-white/[0.04] last:border-0",
i % 2 === 0 ? "bg-transparent" : "bg-white/[0.015]"
)}>
<td className="px-5 py-3.5 text-sm text-white/60">{row.feature}</td>
<td className="px-4 py-3.5 text-center"><Cell val={row.starter} /></td>
<td className="px-4 py-3.5 text-center bg-indigo-500/[0.04]"><Cell val={row.pro} /></td>
<td className="px-4 py-3.5 text-center"><Cell val={row.landlord} /></td>
<td className="px-4 py-3.5 text-center"><Cell val={row.lifetime} /></td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
</div>
</section>
)
}