Files
Leon SerfatyandClaude Opus 5 1d02598786 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>
2026-09-05 16:27:16 -04:00

134 lines
4.5 KiB
TypeScript

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"
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") }}
/>
)
}
// ── 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`,
contactPoint: {
"@type": "ContactPoint",
contactType: "customer support",
email: "support@propertymanagement.network",
},
sameAs: [
"https://twitter.com/propertymgmtnet",
"https://github.com/propertymanagement-network",
],
}
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.",
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",
"@id": `${base}/#faq`,
mainEntity: FAQS.map((faq) => ({
"@type": "Question",
name: faq.q,
acceptedAnswer: {
"@type": "Answer",
text: faq.a,
},
})),
}
/**
* 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 (
<>
<JsonLd data={softwareApplication} />
<JsonLd data={faqPage} />
</>
)
}