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:
co-authored by
Claude Opus 5
parent
8f90347659
commit
1d02598786
@@ -19,15 +19,21 @@ export function PlanDonut({ data }: { data: Record<string, number> }) {
|
||||
const radius = (size - stroke) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
|
||||
// Build cumulative arc segments
|
||||
let cumulative = 0
|
||||
const segments = PLAN_META.map((p) => {
|
||||
const value = data[p.key] ?? 0
|
||||
const fraction = total > 0 ? value / total : 0
|
||||
const dash = fraction * circumference
|
||||
const offset = cumulative * circumference
|
||||
cumulative += fraction
|
||||
return { ...p, value, fraction, dash, offset }
|
||||
// Build cumulative arc segments. The running offset is derived per segment
|
||||
// from the slices before it rather than mutated across the map callback —
|
||||
// reassigning a closed-over local during render is what react-hooks
|
||||
// /immutability flags, and it misbehaves under re-render.
|
||||
const fractions = PLAN_META.map((p) => (total > 0 ? (data[p.key] ?? 0) / total : 0))
|
||||
const segments = PLAN_META.map((p, i) => {
|
||||
const fraction = fractions[i]
|
||||
const precedingFraction = fractions.slice(0, i).reduce((sum, f) => sum + f, 0)
|
||||
return {
|
||||
...p,
|
||||
value: data[p.key] ?? 0,
|
||||
fraction,
|
||||
dash: fraction * circumference,
|
||||
offset: precedingFraction * circumference,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Receipt, RotateCcw, ExternalLink } from "lucide-react"
|
||||
import { refundUserCharge } from "@/app/actions/admin"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type ChargeRow = {
|
||||
id: string
|
||||
amount: number
|
||||
amountRefunded: number
|
||||
currency: string
|
||||
created: number
|
||||
status: string
|
||||
refunded: boolean
|
||||
description: string | null
|
||||
receiptUrl: string | null
|
||||
}
|
||||
|
||||
const REASON_OPTIONS = [
|
||||
{ value: "requested_by_customer", label: "Requested by customer" },
|
||||
{ value: "duplicate", label: "Duplicate charge" },
|
||||
{ value: "fraudulent", label: "Fraudulent" },
|
||||
]
|
||||
|
||||
function money(cents: number, currency: string) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(cents / 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent Stripe charges with a refund control per row.
|
||||
*
|
||||
* Partial refunds are entered in DOLLARS and converted to integer cents here;
|
||||
* the server re-validates the amount against what is actually still refundable
|
||||
* on the charge, so a stale page cannot over-refund.
|
||||
*/
|
||||
export function BillingActions({ userId, charges }: { userId: string; charges: ChargeRow[] }) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [target, setTarget] = useState<ChargeRow | null>(null)
|
||||
const [amount, setAmount] = useState("")
|
||||
const [reason, setReason] = useState("requested_by_customer")
|
||||
|
||||
function openRefund(c: ChargeRow) {
|
||||
setTarget(c)
|
||||
// Default to the full remaining amount, which is the common case.
|
||||
setAmount(((c.amount - c.amountRefunded) / 100).toFixed(2))
|
||||
setReason("requested_by_customer")
|
||||
}
|
||||
|
||||
function submitRefund() {
|
||||
if (!target) return
|
||||
const remaining = target.amount - target.amountRefunded
|
||||
const parsed = Math.round(parseFloat(amount) * 100)
|
||||
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
toast.error("Enter a refund amount greater than zero.")
|
||||
return
|
||||
}
|
||||
if (parsed > remaining) {
|
||||
toast.error(`Only ${money(remaining, target.currency)} is still refundable on that charge.`)
|
||||
return
|
||||
}
|
||||
|
||||
// A full refund sends no amount so Stripe refunds the exact remainder —
|
||||
// avoids a rounding mismatch on odd amounts.
|
||||
const amountCents = parsed === remaining ? undefined : parsed
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await refundUserCharge(
|
||||
userId,
|
||||
target.id,
|
||||
amountCents,
|
||||
reason as "duplicate" | "fraudulent" | "requested_by_customer"
|
||||
)
|
||||
if (res.ok === false) {
|
||||
toast.error(res.error ?? "Refund failed")
|
||||
return
|
||||
}
|
||||
toast.success(res.detail ?? "Refund issued")
|
||||
setTarget(null)
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Refund failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!charges.length) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Receipt className="h-4 w-4 text-emerald-400" />
|
||||
<h2 className="text-sm font-semibold">Payments</h2>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-white/40">
|
||||
No Stripe charges found for this user.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Receipt className="h-4 w-4 text-emerald-400" />
|
||||
<h2 className="text-sm font-semibold">Payments</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-white/40">Most recent charges from Stripe.</p>
|
||||
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full min-w-[520px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-xs text-white/40">
|
||||
<th className="pb-2 font-medium">Date</th>
|
||||
<th className="pb-2 font-medium">Amount</th>
|
||||
<th className="pb-2 font-medium">Status</th>
|
||||
<th className="pb-2 text-right font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{charges.map((c) => {
|
||||
const remaining = c.amount - c.amountRefunded
|
||||
const fullyRefunded = c.refunded || remaining <= 0
|
||||
return (
|
||||
<tr key={c.id} className="border-b border-white/[0.04] last:border-0">
|
||||
<td className="py-3 text-white/70">
|
||||
{new Date(c.created * 1000).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</td>
|
||||
<td className="py-3 text-white">
|
||||
{money(c.amount, c.currency)}
|
||||
{c.amountRefunded > 0 && (
|
||||
<span className="ml-1.5 text-xs text-amber-300/80">
|
||||
−{money(c.amountRefunded, c.currency)} refunded
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md px-1.5 py-0.5 text-xs font-medium",
|
||||
fullyRefunded
|
||||
? "bg-amber-500/10 text-amber-300"
|
||||
: c.status === "succeeded"
|
||||
? "bg-emerald-500/10 text-emerald-300"
|
||||
: "bg-white/[0.06] text-white/50"
|
||||
)}
|
||||
>
|
||||
{fullyRefunded ? "refunded" : c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 text-right">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{c.receiptUrl && (
|
||||
<a
|
||||
href={c.receiptUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-2 py-1 text-xs text-white/50 transition hover:text-white"
|
||||
>
|
||||
Receipt <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openRefund(c)}
|
||||
disabled={isPending || fullyRefunded || c.status !== "succeeded"}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-red-500/25 bg-red-500/10 px-2 py-1 text-xs font-medium text-red-300 transition hover:bg-red-500/20 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" /> Refund
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Refund modal — amount + reason + confirm in ONE step, so the amount
|
||||
field is never hidden behind a confirmation dialog. */}
|
||||
{target && (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center px-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => !isPending && setTarget(null)}
|
||||
/>
|
||||
<div className="relative w-full max-w-sm overflow-hidden rounded-2xl border border-white/[0.08] bg-[#16161f] p-6 shadow-2xl shadow-black/80">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border border-red-500/20 bg-red-500/10 text-red-400">
|
||||
<RotateCcw className="h-6 w-6" />
|
||||
</div>
|
||||
<h2 className="text-center text-base font-bold text-white">Refund payment</h2>
|
||||
<p className="mt-2 text-center text-sm text-white/50">
|
||||
{money(target.amount, target.currency)} charged on{" "}
|
||||
{new Date(target.created * 1000).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
. Up to {money(target.amount - target.amountRefunded, target.currency)} can be
|
||||
refunded. This cannot be undone from here.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-white/40">
|
||||
Amount ({target.currency.toUpperCase()})
|
||||
</label>
|
||||
<input
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
inputMode="decimal"
|
||||
autoFocus
|
||||
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-2.5 text-sm text-white outline-none transition focus:border-red-500/50 focus:ring-1 focus:ring-red-500/30"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-white/40">Reason</label>
|
||||
<Select value={reason} onChange={setReason} options={REASON_OPTIONS} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={() => setTarget(null)}
|
||||
disabled={isPending}
|
||||
className="flex-1 rounded-xl border border-white/10 py-2.5 text-sm font-medium text-white/50 transition hover:border-white/20 hover:text-white disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submitRefund}
|
||||
disabled={isPending}
|
||||
className="flex-1 rounded-xl bg-red-600 py-2.5 text-sm font-semibold text-white transition hover:bg-red-500 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Refunding…" : "Issue refund"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,17 @@
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Ban, ShieldCheck, UserCog, MailCheck, Trash2, Crown } from "lucide-react"
|
||||
import {
|
||||
Ban,
|
||||
ShieldCheck,
|
||||
UserCog,
|
||||
MailCheck,
|
||||
Trash2,
|
||||
Crown,
|
||||
CreditCard,
|
||||
PlayCircle,
|
||||
ShieldAlert,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
changeUserPlan,
|
||||
banUser,
|
||||
@@ -11,17 +21,31 @@ import {
|
||||
impersonateUser,
|
||||
markEmailVerified,
|
||||
deleteUser,
|
||||
cancelUserSubscription,
|
||||
resumeUserSubscription,
|
||||
setUserRole,
|
||||
} from "@/app/actions/admin"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { ConfirmModal } from "@/components/ui/confirm-modal"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type SubscriptionInfo = {
|
||||
id: string
|
||||
status: string
|
||||
cancelAtPeriodEnd: boolean
|
||||
currentPeriodEnd: number | null
|
||||
amount: number | null
|
||||
interval: string | null
|
||||
} | null
|
||||
|
||||
interface UserActionsProps {
|
||||
userId: string
|
||||
email: string
|
||||
currentPlan: string
|
||||
banned: boolean
|
||||
isSelf: boolean
|
||||
isAdminRole: boolean
|
||||
subscription: SubscriptionInfo
|
||||
}
|
||||
|
||||
const PLAN_OPTIONS = [
|
||||
@@ -31,25 +55,60 @@ const PLAN_OPTIONS = [
|
||||
{ value: "lifetime", label: "Lifetime" },
|
||||
]
|
||||
|
||||
// "comp" writes the entitlement only; "stripe" moves real billing. Keeping these
|
||||
// as an explicit choice is the whole point — see changeUserPlan in
|
||||
// app/actions/admin.ts.
|
||||
const MODE_OPTIONS = [
|
||||
{ value: "comp", label: "Comp — entitlement only, no billing change" },
|
||||
{ value: "stripe", label: "Sync to Stripe — charges/credits the customer" },
|
||||
]
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: "month", label: "Monthly" },
|
||||
{ value: "year", label: "Yearly" },
|
||||
]
|
||||
|
||||
function errMessage(e: unknown) {
|
||||
return e instanceof Error ? e.message : "Something went wrong"
|
||||
}
|
||||
|
||||
export function UserActions({ userId, email, currentPlan, banned, isSelf }: UserActionsProps) {
|
||||
/** Server actions here return either a thrown Error or `{ ok: false, error }`. */
|
||||
type ActionResult = { ok?: boolean; error?: string; detail?: string } | void | unknown
|
||||
|
||||
export function UserActions({
|
||||
userId,
|
||||
email,
|
||||
currentPlan,
|
||||
banned,
|
||||
isSelf,
|
||||
isAdminRole,
|
||||
subscription,
|
||||
}: UserActionsProps) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [plan, setPlan] = useState(currentPlan)
|
||||
const [planMode, setPlanMode] = useState<"comp" | "stripe">("comp")
|
||||
const [interval, setInterval] = useState<"month" | "year">("month")
|
||||
const [showBan, setShowBan] = useState(false)
|
||||
const [banReason, setBanReason] = useState("")
|
||||
const [showImpersonate, setShowImpersonate] = useState(false)
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [showStripePlan, setShowStripePlan] = useState(false)
|
||||
const [showCancelNow, setShowCancelNow] = useState(false)
|
||||
const [showRole, setShowRole] = useState(false)
|
||||
|
||||
// Run a server action inside a transition; toast on success/error, then refresh.
|
||||
function run(fn: () => Promise<unknown>, successMsg: string, after?: () => void) {
|
||||
// Run a server action inside a transition. Handles BOTH failure shapes: a
|
||||
// thrown Error (guard/validation) and a returned { ok: false, error } (an
|
||||
// expected Stripe condition, e.g. "no subscription to modify").
|
||||
function run(fn: () => Promise<ActionResult>, fallbackMsg: string, after?: () => void) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await fn()
|
||||
toast.success(successMsg)
|
||||
const res = (await fn()) as { ok?: boolean; error?: string; detail?: string } | undefined
|
||||
if (res && res.ok === false) {
|
||||
toast.error(res.error ?? "Action failed")
|
||||
return
|
||||
}
|
||||
toast.success(res?.detail ?? fallbackMsg)
|
||||
after?.()
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
@@ -58,67 +117,71 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
|
||||
})
|
||||
}
|
||||
|
||||
function onApplyPlan() {
|
||||
if (plan === currentPlan) {
|
||||
function applyPlan(mode: "comp" | "stripe") {
|
||||
if (plan === currentPlan && mode === "comp") {
|
||||
toast.message("Plan unchanged")
|
||||
return
|
||||
}
|
||||
run(() => changeUserPlan(userId, plan), "Plan updated")
|
||||
run(
|
||||
() => changeUserPlan(userId, plan, mode, interval),
|
||||
mode === "stripe" ? "Stripe subscription updated" : "Plan comped",
|
||||
() => setShowStripePlan(false)
|
||||
)
|
||||
}
|
||||
|
||||
function onBan() {
|
||||
run(() => banUser(userId, banReason.trim() || undefined), "User banned", () => {
|
||||
setShowBan(false)
|
||||
setBanReason("")
|
||||
})
|
||||
}
|
||||
|
||||
function onUnban() {
|
||||
run(() => unbanUser(userId), "User unbanned")
|
||||
}
|
||||
|
||||
function onImpersonate() {
|
||||
// impersonateUser redirects to /dashboard on success — no toast needed.
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await impersonateUser(userId)
|
||||
} catch (e) {
|
||||
toast.error(errMessage(e))
|
||||
setShowImpersonate(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onVerify() {
|
||||
run(() => markEmailVerified(userId), "Email marked as verified")
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
// deleteUser redirects to /admin/users on success.
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteUser(userId)
|
||||
} catch (e) {
|
||||
toast.error(errMessage(e))
|
||||
setShowDelete(false)
|
||||
}
|
||||
})
|
||||
function onApplyPlan() {
|
||||
// Moving real money always gets a confirmation step.
|
||||
if (planMode === "stripe") {
|
||||
setShowStripePlan(true)
|
||||
return
|
||||
}
|
||||
applyPlan("comp")
|
||||
}
|
||||
|
||||
const btnBase =
|
||||
"w-full inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
|
||||
const btnGhost =
|
||||
"border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:text-white"
|
||||
|
||||
const periodEnd = subscription?.currentPeriodEnd
|
||||
? new Date(subscription.currentPeriodEnd * 1000).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Plan */}
|
||||
{/* ── Plan ───────────────────────────────────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Crown className="h-4 w-4 text-amber-400" />
|
||||
<h3 className="text-sm font-semibold">Change plan</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-white/40">Override the user's subscription tier.</p>
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
A comp grants access without touching billing. Syncing to Stripe changes what the
|
||||
customer actually pays.
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<Select value={plan} onChange={setPlan} options={PLAN_OPTIONS} />
|
||||
<Select
|
||||
value={planMode}
|
||||
onChange={(v) => setPlanMode(v as "comp" | "stripe")}
|
||||
options={MODE_OPTIONS}
|
||||
/>
|
||||
{planMode === "stripe" && (
|
||||
<Select
|
||||
value={interval}
|
||||
onChange={(v) => setInterval(v as "month" | "year")}
|
||||
options={INTERVAL_OPTIONS}
|
||||
/>
|
||||
)}
|
||||
{planMode === "stripe" && (
|
||||
<p className="rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-xs text-amber-300/90">
|
||||
This charges or credits the customer immediately via proration.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={onApplyPlan}
|
||||
disabled={isPending}
|
||||
@@ -129,19 +192,125 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account actions */}
|
||||
{/* ── Subscription ───────────────────────────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<CreditCard className="h-4 w-4 text-sky-400" />
|
||||
<h3 className="text-sm font-semibold">Subscription</h3>
|
||||
</div>
|
||||
|
||||
{subscription ? (
|
||||
<>
|
||||
<div className="mt-3 space-y-1 text-xs text-white/50">
|
||||
<div>
|
||||
Status:{" "}
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium",
|
||||
subscription.status === "active" ? "text-emerald-300" : "text-amber-300"
|
||||
)}
|
||||
>
|
||||
{subscription.status}
|
||||
</span>
|
||||
</div>
|
||||
{subscription.amount !== null && (
|
||||
<div>
|
||||
${(subscription.amount / 100).toFixed(2)}
|
||||
{subscription.interval ? ` / ${subscription.interval}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{periodEnd && (
|
||||
<div>
|
||||
{subscription.cancelAtPeriodEnd ? "Cancels" : "Renews"} {periodEnd}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2.5">
|
||||
{subscription.cancelAtPeriodEnd ? (
|
||||
<button
|
||||
onClick={() => run(() => resumeUserSubscription(userId), "Subscription resumed")}
|
||||
disabled={isPending}
|
||||
className={cn(
|
||||
btnBase,
|
||||
"border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20"
|
||||
)}
|
||||
>
|
||||
<PlayCircle className="h-4 w-4" /> Resume subscription
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() =>
|
||||
run(() => cancelUserSubscription(userId, false), "Cancellation scheduled")
|
||||
}
|
||||
disabled={isPending}
|
||||
className={cn(btnBase, btnGhost)}
|
||||
>
|
||||
Cancel at period end
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowCancelNow(true)}
|
||||
disabled={isPending}
|
||||
className={cn(
|
||||
btnBase,
|
||||
"border border-red-500/30 bg-red-500/10 text-red-300 hover:bg-red-500/20"
|
||||
)}
|
||||
>
|
||||
Cancel immediately
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-3 text-xs text-white/40">
|
||||
No active Stripe subscription. Plan changes for this user must be comps.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Admin role ─────────────────────────────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<ShieldAlert className="h-4 w-4 text-violet-400" />
|
||||
<h3 className="text-sm font-semibold">Admin access</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
{isAdminRole
|
||||
? "This user has full admin access to the platform."
|
||||
: "Grant full access to the admin dashboard and every account."}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowRole(true)}
|
||||
disabled={isPending || isSelf}
|
||||
title={isSelf ? "You cannot change your own role" : undefined}
|
||||
className={cn(
|
||||
btnBase,
|
||||
"mt-4",
|
||||
isAdminRole
|
||||
? "border border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
|
||||
: "border border-violet-500/30 bg-violet-500/10 text-violet-300 hover:bg-violet-500/20"
|
||||
)}
|
||||
>
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
{isAdminRole ? "Revoke admin access" : "Make admin"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Account actions ────────────────────────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<UserCog className="h-4 w-4 text-rose-400" />
|
||||
<h3 className="text-sm font-semibold">Account</h3>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2.5">
|
||||
{/* Ban / Unban */}
|
||||
{banned ? (
|
||||
<button
|
||||
onClick={onUnban}
|
||||
onClick={() => run(() => unbanUser(userId), "User unbanned")}
|
||||
disabled={isPending}
|
||||
className={cn(btnBase, "border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20")}
|
||||
className={cn(
|
||||
btnBase,
|
||||
"border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20"
|
||||
)}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" /> Unban user
|
||||
</button>
|
||||
@@ -150,34 +319,35 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
|
||||
onClick={() => setShowBan(true)}
|
||||
disabled={isPending || isSelf}
|
||||
title={isSelf ? "You cannot ban yourself" : undefined}
|
||||
className={cn(btnBase, "border border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20")}
|
||||
className={cn(
|
||||
btnBase,
|
||||
"border border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
|
||||
)}
|
||||
>
|
||||
<Ban className="h-4 w-4" /> Ban user
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Impersonate */}
|
||||
<button
|
||||
onClick={() => setShowImpersonate(true)}
|
||||
disabled={isPending || isSelf}
|
||||
title={isSelf ? "You cannot impersonate yourself" : undefined}
|
||||
className={cn(btnBase, "border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:text-white")}
|
||||
className={cn(btnBase, btnGhost)}
|
||||
>
|
||||
<UserCog className="h-4 w-4" /> Impersonate
|
||||
</button>
|
||||
|
||||
{/* Verify email */}
|
||||
<button
|
||||
onClick={onVerify}
|
||||
onClick={() => run(() => markEmailVerified(userId), "Email marked as verified")}
|
||||
disabled={isPending}
|
||||
className={cn(btnBase, "border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:text-white")}
|
||||
className={cn(btnBase, btnGhost)}
|
||||
>
|
||||
<MailCheck className="h-4 w-4" /> Mark email verified
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
{/* ── Danger zone ────────────────────────────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-red-500/20 bg-red-500/[0.03] p-5">
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -199,7 +369,10 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
|
||||
{/* Ban modal (with reason input) */}
|
||||
{showBan && (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center px-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => !isPending && setShowBan(false)} />
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => !isPending && setShowBan(false)}
|
||||
/>
|
||||
<div className="relative w-full max-w-sm overflow-hidden rounded-2xl border border-white/[0.08] bg-[#16161f] p-6 shadow-2xl shadow-black/80">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border border-amber-500/20 bg-amber-500/10 text-amber-400">
|
||||
<Ban className="h-6 w-6" />
|
||||
@@ -209,7 +382,9 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
|
||||
The user will be signed out and blocked from signing in until unbanned.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<label className="mb-1.5 block text-xs font-medium text-white/40">Reason (optional)</label>
|
||||
<label className="mb-1.5 block text-xs font-medium text-white/40">
|
||||
Reason (optional)
|
||||
</label>
|
||||
<input
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
@@ -226,7 +401,12 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onBan}
|
||||
onClick={() =>
|
||||
run(() => banUser(userId, banReason.trim() || undefined), "User banned", () => {
|
||||
setShowBan(false)
|
||||
setBanReason("")
|
||||
})
|
||||
}
|
||||
disabled={isPending}
|
||||
className="flex-1 rounded-xl bg-amber-600 py-2.5 text-sm font-semibold text-white transition hover:bg-amber-500 disabled:opacity-50"
|
||||
>
|
||||
@@ -237,7 +417,53 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Impersonate confirm */}
|
||||
<ConfirmModal
|
||||
open={showStripePlan}
|
||||
variant="warning"
|
||||
title={`Move ${email} to ${plan} in Stripe?`}
|
||||
description="This updates their live Stripe subscription with proration — the customer will be charged or credited the difference immediately. Choose the Comp option instead to grant access without billing them."
|
||||
confirmLabel="Update billing"
|
||||
loading={isPending}
|
||||
onConfirm={() => applyPlan("stripe")}
|
||||
onCancel={() => setShowStripePlan(false)}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={showCancelNow}
|
||||
variant="danger"
|
||||
title="Cancel immediately?"
|
||||
description="Access ends right now and the plan resets to Starter. No refund is issued automatically — refund the charge separately if that's intended. To let them keep what they paid for, cancel at period end instead."
|
||||
confirmLabel="Cancel now"
|
||||
loading={isPending}
|
||||
onConfirm={() =>
|
||||
run(() => cancelUserSubscription(userId, true), "Subscription canceled", () =>
|
||||
setShowCancelNow(false)
|
||||
)
|
||||
}
|
||||
onCancel={() => setShowCancelNow(false)}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={showRole}
|
||||
variant={isAdminRole ? "warning" : "danger"}
|
||||
title={isAdminRole ? `Revoke admin from ${email}?` : `Make ${email} an admin?`}
|
||||
description={
|
||||
isAdminRole
|
||||
? "They will lose access to the admin dashboard immediately."
|
||||
: "They will gain full access to every account on the platform, including billing actions and user deletion. Grant this only to staff you trust completely."
|
||||
}
|
||||
confirmLabel={isAdminRole ? "Revoke access" : "Make admin"}
|
||||
loading={isPending}
|
||||
onConfirm={() =>
|
||||
run(
|
||||
() => setUserRole(userId, isAdminRole ? "user" : "admin"),
|
||||
isAdminRole ? "Admin access revoked" : "User promoted to admin",
|
||||
() => setShowRole(false)
|
||||
)
|
||||
}
|
||||
onCancel={() => setShowRole(false)}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={showImpersonate}
|
||||
variant="warning"
|
||||
@@ -245,11 +471,19 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
|
||||
description="You will be signed in as this user and redirected to their dashboard. Your admin session can be restored from the impersonation banner."
|
||||
confirmLabel="Impersonate"
|
||||
loading={isPending}
|
||||
onConfirm={onImpersonate}
|
||||
onConfirm={() => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await impersonateUser(userId)
|
||||
} catch (e) {
|
||||
toast.error(errMessage(e))
|
||||
setShowImpersonate(false)
|
||||
}
|
||||
})
|
||||
}}
|
||||
onCancel={() => setShowImpersonate(false)}
|
||||
/>
|
||||
|
||||
{/* Delete confirm */}
|
||||
<ConfirmModal
|
||||
open={showDelete}
|
||||
variant="danger"
|
||||
@@ -257,7 +491,16 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
|
||||
description="This permanently deletes the user and ALL their data — properties, units, tenants, leases, payments and more. This action cannot be undone."
|
||||
confirmLabel="Delete user"
|
||||
loading={isPending}
|
||||
onConfirm={onDelete}
|
||||
onConfirm={() => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteUser(userId)
|
||||
} catch (e) {
|
||||
toast.error(errMessage(e))
|
||||
setShowDelete(false)
|
||||
}
|
||||
})
|
||||
}}
|
||||
onCancel={() => setShowDelete(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user