chore: sync in-progress work across marketing, admin, API and tests

Snapshot of uncommitted work that had accumulated in the tree alongside
the Turnstile changes:

- marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and
  structured data
- admin billing actions and a per-user portfolio view, plus an admin
  error boundary
- rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface
- CSP and proxy adjustments, accounting/webhook lib updates
- Playwright config and an e2e/unit test suite
- next bumped to ^16.3.4 with the lockfile regenerated
- generated AGENTS.md / CLAUDE.md

Authored by other sessions working in this tree; committed here so the
Turnstile work could be pushed without leaving the tree dirty.
Typecheck passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-09-05 16:27:16 -04:00
co-authored by Claude Opus 5
parent 8f90347659
commit 1d02598786
71 changed files with 3641 additions and 819 deletions
+58 -73
View File
@@ -1,43 +1,9 @@
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { motion } from "framer-motion"
import { Plus, Minus } from "lucide-react"
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.",
},
]
import { FAQS } from "@/lib/marketing/faqs"
export function FAQ() {
const [open, setOpen] = useState<number | null>(null)
@@ -56,45 +22,64 @@ export function FAQ() {
</motion.div>
<div className="space-y-3">
{FAQS.map((faq, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.05 }}
className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden"
>
<button
onClick={() => setOpen(open === i ? null : i)}
className="flex w-full items-center justify-between px-5 py-4 text-left"
{FAQS.map((faq, i) => {
const isOpen = open === i
return (
<motion.div
key={i}
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.05 }}
className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden"
>
<span className="text-sm font-medium text-white pr-4">{faq.q}</span>
<div className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition-colors ${
open === i ? "border-indigo-500/40 bg-indigo-500/10" : "border-white/10"
}`}>
{open === i
? <Minus className="h-3 w-3 text-indigo-400" />
: <Plus className="h-3 w-3 text-white/50" />
}
</div>
</button>
<AnimatePresence initial={false}>
{open === i && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
<h3>
<button
type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
aria-controls={`faq-answer-${i}`}
id={`faq-question-${i}`}
className="flex w-full items-center justify-between px-5 py-4 text-left"
>
<p className="px-5 pb-5 text-sm text-white/50 leading-relaxed border-t border-white/[0.04] pt-3">
{faq.a}
</p>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
<span className="text-sm font-medium text-white pr-4">{faq.q}</span>
<div
className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition-colors ${
isOpen ? "border-indigo-500/40 bg-indigo-500/10" : "border-white/10"
}`}
>
{isOpen ? (
<Minus className="h-3 w-3 text-indigo-400" />
) : (
<Plus className="h-3 w-3 text-white/50" />
)}
</div>
</button>
</h3>
{/*
The answer stays mounted and is collapsed by animating its height
rather than being conditionally rendered. Google requires the
answer text behind an FAQ accordion to be present in the served
HTML — unmounting it when closed would leave the FAQPage JSON-LD
in components/marketing/structured-data.tsx describing content no
crawler can see.
*/}
<motion.div
id={`faq-answer-${i}`}
role="region"
aria-labelledby={`faq-question-${i}`}
initial={false}
animate={{ height: isOpen ? "auto" : 0, opacity: isOpen ? 1 : 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
className="overflow-hidden"
>
<p className="px-5 pb-5 text-sm text-white/50 leading-relaxed border-t border-white/[0.04] pt-3">
{faq.a}
</p>
</motion.div>
</motion.div>
)
})}
</div>
</section>
)
+4 -4
View File
@@ -5,10 +5,10 @@ import { LEGAL_PAGES } from "@/lib/legal"
const LINKS = {
Product: [
{ label: "Features", href: "#features" },
{ label: "Pricing", href: "#pricing" },
{ label: "How it works", href: "#how-it-works" },
{ label: "FAQ", href: "#faq" },
{ label: "Features", href: "/#features" },
{ label: "Pricing", href: "/#pricing" },
{ label: "How it works", href: "/#how-it-works" },
{ label: "FAQ", href: "/#faq" },
],
Platform: [
{ label: "Dashboard", href: "/login" },
+4 -4
View File
@@ -7,10 +7,10 @@ import { Menu, X, ArrowRight } from "lucide-react"
import { Logo } from "@/components/shared/logo"
const NAV_LINKS = [
{ label: "Features", href: "#features" },
{ label: "How it works", href: "#how-it-works" },
{ label: "Pricing", href: "#pricing" },
{ label: "FAQ", href: "#faq" },
{ label: "Features", href: "/#features" },
{ label: "How it works", href: "/#how-it-works" },
{ label: "Pricing", href: "/#pricing" },
{ label: "FAQ", href: "/#faq" },
]
export function Navbar() {
+75 -65
View File
@@ -1,59 +1,28 @@
import { PLAN_AMOUNTS, getPlanLabel } from "@/lib/stripe/plans"
import { FAQS } from "@/lib/marketing/faqs"
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",
}))
function JsonLd({ data }: { data: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
// JSON.stringify output is escaped for the closing-tag sequence so a value
// containing "</script>" can't break out of the block.
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\u003c") }}
/>
)
}
// 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.",
},
]
// ── Site-wide entities ───────────────────────────────────────────
// Organization and WebSite describe the publisher and the site itself, so they
// are valid on every page of the marketing surface.
const organization: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${base}/#organization`,
name: "Property Management Network",
url: base,
logo: `${base}/logo-mark.png`,
@@ -71,25 +40,76 @@ const organization: Record<string, unknown> = {
const website: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": `${base}/#website`,
name: "Property Management Network",
url: base,
publisher: { "@id": `${base}/#organization` },
}
/**
* Organization + WebSite JSON-LD. Safe to render on every marketing page —
* both describe the site as a whole rather than the content of one page.
*/
export function SiteStructuredData() {
return (
<>
<JsonLd data={organization} />
<JsonLd data={website} />
</>
)
}
// ── Home-page-only entities ──────────────────────────────────────
// SoftwareApplication describes the product presented on the landing page, and
// FAQPage MUST only be emitted where the same questions and answers are visible
// to the user (Google's FAQ structured data policy). Both therefore belong to
// `/` alone and must NOT be moved into the shared marketing layout.
// 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 planPrices = planOrder.map((plan) => PLAN_AMOUNTS[plan])
const planOffers = planOrder.map((plan) => ({
"@type": "Offer",
name: getPlanLabel(plan),
price: String(PLAN_AMOUNTS[plan]),
priceCurrency: "USD",
url: `${base}/#pricing`,
availability: "https://schema.org/InStock",
}))
const softwareApplication: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"@id": `${base}/#software`,
name: "Property Management Network",
url: base,
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: planOffers,
publisher: { "@id": `${base}/#organization` },
// AggregateOffer is the correct wrapper for a product sold at several price
// points; the individual plan Offers are nested inside it.
offers: {
"@type": "AggregateOffer",
priceCurrency: "USD",
lowPrice: String(Math.min(...planPrices)),
highPrice: String(Math.max(...planPrices)),
offerCount: planOffers.length,
offers: planOffers,
},
}
// Mirrors the visible FAQ rendered by components/marketing/faq.tsx — both read
// the same lib/marketing/faqs.ts list, so the markup can never drift from the
// copy on the page.
const faqPage: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@id": `${base}/#faq`,
mainEntity: FAQS.map((faq) => ({
"@type": "Question",
name: faq.q,
acceptedAnswer: {
@@ -99,25 +119,15 @@ const faqPage: Record<string, unknown> = {
})),
}
export function StructuredData() {
/**
* SoftwareApplication + FAQPage JSON-LD. Render this ONLY on `/`, which is the
* page that actually shows the pricing table and the FAQ accordion.
*/
export function HomeStructuredData() {
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) }}
/>
<JsonLd data={softwareApplication} />
<JsonLd data={faqPage} />
</>
)
}