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>
134 lines
4.5 KiB
TypeScript
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} />
|
|
</>
|
|
)
|
|
}
|