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>
307 lines
14 KiB
TypeScript
307 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import Link from "next/link"
|
|
import { motion, animate, useMotionValue, useInView } from "framer-motion"
|
|
import { ArrowRight, Building2, Wrench, CheckCircle2, Bell } from "lucide-react"
|
|
import { useRef, useEffect } from "react"
|
|
|
|
// ── Animated counter ─────────────────────────────────────────────
|
|
function Counter({ to, prefix = "", suffix = "" }: { to: number; prefix?: string; suffix?: string }) {
|
|
const ref = useRef<HTMLSpanElement>(null)
|
|
const inView = useInView(ref, { once: true, margin: "-80px" })
|
|
|
|
useEffect(() => {
|
|
if (!inView) return
|
|
const ctrl = animate(0, to, {
|
|
duration: 2,
|
|
ease: "easeOut",
|
|
onUpdate: (v) => {
|
|
if (ref.current) {
|
|
ref.current.textContent = prefix + Math.floor(v).toLocaleString() + suffix
|
|
}
|
|
},
|
|
})
|
|
return ctrl.stop
|
|
}, [inView, to, prefix, suffix])
|
|
|
|
return <span ref={ref}>{prefix}0{suffix}</span>
|
|
}
|
|
|
|
// ── Word-by-word text reveal ─────────────────────────────────────
|
|
function WordReveal({ text, className = "", delay = 0 }: { text: string; className?: string; delay?: number }) {
|
|
const words = text.split(" ")
|
|
return (
|
|
<span className={className}>
|
|
{words.map((word, i) => (
|
|
<motion.span
|
|
key={i}
|
|
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]"
|
|
>
|
|
{word}
|
|
</motion.span>
|
|
))}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ── Gradient border CTA button ───────────────────────────────────
|
|
function GradientBorderBtn({ href, children }: { href: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="gradient-border-wrapper">
|
|
<Link href={href} className="gradient-border-btn">
|
|
{children}
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Mini dashboard mockup ────────────────────────────────────────
|
|
const DASHBOARD_CARDS = [
|
|
{ label: "Rent collected", value: "$12,400", sub: "This month", color: "text-emerald-400", dot: "bg-emerald-400" },
|
|
{ label: "Open requests", value: "3", sub: "Maintenance", color: "text-amber-400", dot: "bg-amber-400" },
|
|
{ label: "Occupancy", value: "94%", sub: "12/13 units", color: "text-indigo-400", dot: "bg-indigo-400" },
|
|
]
|
|
|
|
const STATS = [
|
|
{ prefix: "", to: 200, suffix: "+", label: "Active landlords" },
|
|
{ prefix: "", to: 5000, suffix: "+", label: "Units managed" },
|
|
{ prefix: "$", to: 2, suffix: "M+", label: "Rent tracked / mo" },
|
|
{ prefix: "", to: 98, suffix: "%", label: "Collection rate" },
|
|
]
|
|
|
|
function DashboardMockup() {
|
|
return (
|
|
<motion.div
|
|
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"
|
|
>
|
|
<div className="absolute -inset-4 bg-gradient-to-r from-indigo-600/20 via-violet-600/20 to-indigo-600/20 rounded-3xl blur-3xl" />
|
|
|
|
<div className="relative rounded-2xl border border-white/[0.08] bg-[#0f0f17] overflow-hidden shadow-2xl shadow-black/60">
|
|
{/* Browser chrome */}
|
|
<div className="flex items-center gap-2 border-b border-white/[0.06] bg-[#0a0a12] px-4 py-3">
|
|
<div className="flex gap-1.5">
|
|
<div className="h-3 w-3 rounded-full bg-red-500/60" />
|
|
<div className="h-3 w-3 rounded-full bg-amber-500/60" />
|
|
<div className="h-3 w-3 rounded-full bg-emerald-500/60" />
|
|
</div>
|
|
<div className="flex-1 mx-4 rounded-md bg-white/[0.04] px-3 py-1 text-xs text-white/30 font-mono">
|
|
app.propertymanagement.network/dashboard
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-5 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs text-white/40">Good morning 👋</p>
|
|
<p className="text-sm font-semibold text-white">Dashboard Overview</p>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 rounded-lg bg-indigo-600/20 border border-indigo-500/30 px-3 py-1.5">
|
|
<Bell className="h-3.5 w-3.5 text-indigo-400" />
|
|
<span className="text-xs text-indigo-300">2 alerts</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{DASHBOARD_CARDS.map((c) => (
|
|
<div key={c.label} className="rounded-xl border border-white/[0.06] bg-white/[0.03] p-3">
|
|
<div className="flex items-center gap-1.5 mb-1.5">
|
|
<div className={`h-1.5 w-1.5 rounded-full ${c.dot}`} />
|
|
<span className="text-[10px] text-white/40">{c.label}</span>
|
|
</div>
|
|
<p className={`text-lg font-bold ${c.color}`}>{c.value}</p>
|
|
<p className="text-[10px] text-white/30">{c.sub}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] overflow-hidden">
|
|
<div className="px-4 py-2.5 border-b border-white/[0.04]">
|
|
<p className="text-xs font-medium text-white/60">Recent Payments</p>
|
|
</div>
|
|
{[
|
|
{ name: "Sarah J.", amount: "$1,800", status: "paid", color: "text-emerald-400 bg-emerald-500/10" },
|
|
{ name: "Marcus L.", amount: "$1,350", status: "overdue", color: "text-red-400 bg-red-500/10" },
|
|
{ name: "Priya P.", amount: "$2,200", status: "pending", color: "text-amber-400 bg-amber-500/10" },
|
|
].map((row) => (
|
|
<div key={row.name} className="flex items-center justify-between px-4 py-2.5 border-b border-white/[0.03] last:border-0">
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-6 w-6 rounded-full bg-indigo-600/30 flex items-center justify-center text-[9px] font-bold text-indigo-300">
|
|
{row.name[0]}
|
|
</div>
|
|
<span className="text-xs text-white/70">{row.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-semibold text-white">{row.amount}</span>
|
|
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${row.color}`}>{row.status}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Floating cards */}
|
|
<motion.div
|
|
animate={{ y: [0, -6, 0] }}
|
|
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
|
|
className="absolute -right-8 top-16 hidden lg:block"
|
|
>
|
|
<div className="rounded-xl border border-emerald-500/20 bg-[#0f0f17]/90 backdrop-blur p-3 shadow-xl min-w-[140px]">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
|
<span className="text-xs font-semibold text-white">Rent received</span>
|
|
</div>
|
|
<p className="text-lg font-bold text-emerald-400">$1,800</p>
|
|
<p className="text-[10px] text-white/40">Unit 1A · Sarah J.</p>
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
animate={{ y: [0, 6, 0] }}
|
|
transition={{ duration: 3.5, repeat: Infinity, ease: "easeInOut", delay: 0.5 }}
|
|
className="absolute -left-8 bottom-24 hidden lg:block"
|
|
>
|
|
<div className="rounded-xl border border-amber-500/20 bg-[#0f0f17]/90 backdrop-blur p-3 shadow-xl min-w-[160px]">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Wrench className="h-4 w-4 text-amber-400" />
|
|
<span className="text-xs font-semibold text-white">New request</span>
|
|
</div>
|
|
<p className="text-xs text-white/60">Leaking faucet</p>
|
|
<p className="text-[10px] text-white/40 mt-0.5">Unit 2B · 2 min ago</p>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)
|
|
}
|
|
|
|
// ── Hero ──────────────────────────────────────────────────────────
|
|
export function Hero() {
|
|
return (
|
|
<section className="relative overflow-hidden pt-24 sm:pt-32 pb-12 sm:pb-16 min-h-screen flex flex-col justify-center">
|
|
{/* Aurora background */}
|
|
<div className="absolute inset-0 -z-10">
|
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 h-[600px] w-[900px] rounded-full bg-indigo-600/20 blur-[120px]" />
|
|
<div className="absolute top-20 left-1/4 h-[400px] w-[400px] rounded-full bg-violet-600/15 blur-[100px]" />
|
|
<div className="absolute top-40 right-1/4 h-[300px] w-[300px] rounded-full bg-blue-600/10 blur-[80px]" />
|
|
{/* Grid */}
|
|
<div className="absolute inset-0" style={{
|
|
backgroundImage: "linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px)",
|
|
backgroundSize: "60px 60px",
|
|
}} />
|
|
{/* Noise */}
|
|
<div className="absolute inset-0 opacity-[0.025]" style={{
|
|
backgroundImage: "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
|
|
}} />
|
|
</div>
|
|
|
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 w-full">
|
|
<div className="grid lg:grid-cols-2 gap-10 lg:gap-16 items-center">
|
|
{/* Left — copy */}
|
|
<div>
|
|
{/* Badge */}
|
|
<motion.div
|
|
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"
|
|
>
|
|
<span className="relative flex h-1.5 w-1.5">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75" />
|
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-indigo-400" />
|
|
</span>
|
|
Trusted by 200+ landlords worldwide
|
|
</motion.div>
|
|
|
|
{/* Headline with word reveal */}
|
|
<h1 className="text-4xl font-bold leading-[1.1] tracking-tight sm:text-5xl lg:text-6xl">
|
|
<WordReveal text="Property management" delay={0.1} />
|
|
{" "}
|
|
<br className="hidden sm:block" />
|
|
<span className="bg-gradient-to-r from-indigo-400 via-violet-400 to-indigo-400 bg-clip-text text-transparent bg-[length:200%] animate-gradient">
|
|
<WordReveal text="without the chaos" delay={0.4} />
|
|
</span>
|
|
</h1>
|
|
|
|
<motion.p
|
|
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"
|
|
>
|
|
Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords.
|
|
</motion.p>
|
|
|
|
{/* CTAs */}
|
|
<motion.div
|
|
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"
|
|
>
|
|
{/* Gradient border primary CTA */}
|
|
<div className="gradient-border-wrapper">
|
|
<Link
|
|
href="/signup"
|
|
className="gradient-border-btn flex items-center gap-2"
|
|
>
|
|
Start for free <ArrowRight className="h-4 w-4" />
|
|
</Link>
|
|
</div>
|
|
|
|
<Link
|
|
href="#features"
|
|
className="flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-6 py-3.5 text-sm font-medium text-white/70 hover:text-white hover:bg-white/[0.08] transition-all"
|
|
>
|
|
See how it works
|
|
</Link>
|
|
</motion.div>
|
|
|
|
{/* Trust signals */}
|
|
<motion.div
|
|
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"
|
|
>
|
|
{["Free plan forever", "No credit card required", "Setup in 5 minutes"].map((t) => (
|
|
<div key={t} className="flex items-center gap-1.5">
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
|
|
{t}
|
|
</div>
|
|
))}
|
|
</motion.div>
|
|
|
|
{/* Animated stats */}
|
|
<motion.div
|
|
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]"
|
|
>
|
|
{STATS.map(({ prefix, to, suffix, label }) => (
|
|
<div key={label}>
|
|
<p className="text-2xl font-bold text-white tabular-nums">
|
|
<Counter prefix={prefix} to={to} suffix={suffix} />
|
|
</p>
|
|
<p className="text-xs text-white/40 mt-0.5">{label}</p>
|
|
</div>
|
|
))}
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Right — dashboard mockup */}
|
|
<div className="hidden lg:flex justify-center">
|
|
<DashboardMockup />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|