feat(web): shared plan cards, public /plans and /buy pages

Community/Pro comparison cards extracted into a shared module
(components/plans: planData.ts as single copy source, PlanCards.tsx with
marketing/portal surfaces) so the portal Plan section and the new public
/plans page can never drift. Pro price is one constant (null renders
"Pricing - announced soon").

New public pages following the /help pattern: /plans (free-first
messaging, nav item "Plans") and /buy (direct purchase + Microsoft
Marketplace as coming-soon channels). Landing nav, footer, FinalCta and
get-started now link to /plans instead of the docs plans page; portal
Plan section links to /buy via absolute www URL. Purple utilities
neutralized inside .dark .landing-v2; sitemap/robots/lastmod cover
/plans, /buy and the previously missing /help.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-22 19:46:44 +02:00
parent 65d45a616e
commit 4e54ffe4b4
17 changed files with 582 additions and 235 deletions
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/utils/config";
export const metadata: Metadata = {
title: "Get Pro – Purchase Options",
description:
"How to purchase Autopilot Monitor Pro: direct online purchase and the Microsoft commercial marketplace — both coming soon. Pricing will be announced; Community stays free.",
keywords: [
"Autopilot Monitor Pro",
"Autopilot Monitor buy",
"Autopilot Monitor purchase",
"Autopilot Monitor marketplace",
"Windows Autopilot monitoring Pro",
],
openGraph: {
title: "Get Pro – Autopilot Monitor",
description:
"How to purchase Autopilot Monitor Pro: direct online purchase and the Microsoft commercial marketplace — both coming soon.",
url: `${SITE_URL}/buy`,
},
twitter: {
title: "Get Pro – Autopilot Monitor",
description:
"How to purchase Autopilot Monitor Pro: direct online purchase and the Microsoft commercial marketplace — both coming soon.",
},
alternates: {
canonical: `${SITE_URL}/buy`,
},
};
export default function BuyLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
@@ -0,0 +1,85 @@
import Link from "next/link";
import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
/**
* Purchase page for Pro. Linked from the public /plans page and from the
* portal's Plan section (absolute www URL — /buy is public surface).
*
* Both purchase channels are honest "coming soon" placeholders until the
* checkout links exist. When they do: the provider host must be registered in
* utils/config.ts, the CSP in staticwebapp.config.json, the swaConfig guard
* test, and the dev CSP in next.config.ts — see tasks/todo.md follow-up.
*/
const CHANNELS = [
{
label: "Direct purchase",
description:
"Buy Pro online with a credit card or on invoice — a fast, self-service checkout for a single organization.",
},
{
label: "Microsoft Marketplace",
description:
"Purchase through the Microsoft commercial marketplace using your organization's existing Microsoft billing relationship.",
},
];
export default function BuyPage() {
return (
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
<LandingNavbar />
<header className="px-4 sm:px-6 lg:px-8 pt-14 sm:pt-16">
<div className="max-w-5xl mx-auto">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">Pro</p>
<h1 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)]">
Get Autopilot Monitor Pro
</h1>
<p className="mt-4 max-w-2xl text-[15px] text-gray-600 leading-relaxed">
Pro will be available through two purchase channels. Neither is open yet — pricing
and availability will be announced. Until then, Community is the full product, free
for everyone.
</p>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-10 text-[15px]">
{/* Purchase channels */}
<section className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{CHANNELS.map((channel) => (
<div key={channel.label} className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8 flex flex-col">
<h2 className="text-lg font-bold text-gray-900 mb-3">{channel.label}</h2>
<p className="text-sm text-gray-600 leading-relaxed mb-4 flex-1">{channel.description}</p>
<button
type="button"
disabled
title="Available once Pro pricing is announced."
className="w-full rounded-lg border border-[var(--lp-line-soft)] bg-[var(--lp-surface-2)] px-4 py-2.5 text-sm font-semibold text-[var(--lp-ink-faint)] cursor-not-allowed"
>
Coming soon
</button>
</div>
))}
</section>
{/* Interim path */}
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">Questions About Pro?</h2>
<p className="text-gray-700 leading-relaxed">
Interested in Pro for your organization or an MSP scenario, or want to know when it
opens? Reach out through any channel on the{" "}
<Link href="/help" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">
Help &amp; Support
</Link>{" "}
page — you&apos;ll talk directly to the person building the platform. And if you
haven&apos;t compared the plans yet, the{" "}
<Link href="/plans" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">
plans overview
</Link>{" "}
shows exactly what Pro adds.
</p>
</section>
</main>
<SiteFooter />
</div>
);
}
@@ -103,9 +103,7 @@ export default function GetStartedPage() {
<p className="mt-6 text-sm text-[var(--lp-ink-faint)] leading-relaxed max-w-xl">
When you need more later: the{" "}
<a
href={`${DOCS_URL}/plans`}
target="_blank"
rel="noopener noreferrer"
href="/plans"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
Pro plan
@@ -861,6 +861,14 @@ html:hover ::-webkit-scrollbar-thumb {
.dark .landing-v2 .border-gray-300 { border-color: #d1d5db !important; }
.dark .landing-v2 .hover\:bg-gray-50:hover { background-color: #f9fafb !important; }
.dark .landing-v2 .hover\:bg-gray-100:hover { background-color: #f3f4f6 !important; }
/* Purple utilities on the shared plan cards (/plans): only the classes that
HAVE a global .dark override need neutralizing here — the slash-opacity
surfaces (bg-purple-50/20) are separate tokens the override map never
matched in the first place. */
.dark .landing-v2 .text-purple-900 { color: #581c87 !important; }
.dark .landing-v2 .text-purple-700 { color: #7e22ce !important; }
.dark .landing-v2 .bg-purple-100 { background-color: #f3e8ff !important; }
.dark .landing-v2 .border-purple-200 { border-color: #e9d5ff !important; }
/* Scroll-triggered reveal: Reveal.tsx adds .lp-in when the element
enters the viewport. Reduced motion renders everything visible. */
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { SITE_URL } from "@/utils/config";
export const metadata: Metadata = {
title: "Plans – Community & Pro",
description:
"Autopilot Monitor plans: Community is the full product and free — live session monitoring, rules engine, fleet analytics, and AI integration. Pro adds extended retention, higher limits, MSP delegation, and priority support.",
keywords: [
"Autopilot Monitor plans",
"Autopilot Monitor pricing",
"Autopilot Monitor free",
"Autopilot Monitor Pro",
"Windows Autopilot monitoring free",
"Autopilot monitoring MSP",
],
openGraph: {
title: "Plans – Autopilot Monitor",
description:
"Community is the full product and free — and stays free. Pro adds extended retention, higher limits, MSP delegation, and priority support.",
url: `${SITE_URL}/plans`,
},
twitter: {
title: "Plans – Autopilot Monitor",
description:
"Community is the full product and free — and stays free. Pro adds extended retention, higher limits, MSP delegation, and priority support.",
},
alternates: {
canonical: `${SITE_URL}/plans`,
},
};
export default function PlansLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
@@ -0,0 +1,72 @@
import Link from "next/link";
import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { PlanCards } from "../../components/plans/PlanCards";
import { DOCS_URL } from "@/utils/config";
/**
* Public plans page. Deliberately named "Plans" (not "Pricing"): the message is
* free-first — Community is the full product and stays free; Pro is the optional
* step up. The cards are the shared PlanCards, so this page can never drift from
* the portal's Plan section.
*/
export default function PlansPage() {
return (
<div className="landing-v2 min-h-screen bg-[var(--lp-bg)]">
<LandingNavbar />
<header className="px-4 sm:px-6 lg:px-8 pt-14 sm:pt-16">
<div className="max-w-5xl mx-auto">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--lp-ink-faint)]">Plans</p>
<h1 className="mt-3 text-3xl sm:text-4xl font-bold tracking-tight text-[var(--lp-ink)]">
The full product is free — and stays free.
</h1>
<p className="mt-4 max-w-2xl text-[15px] text-gray-600 leading-relaxed">
Community is not a trial and not a cut-down teaser: live monitoring, the full rules
engine, fleet analytics, and AI integration are free for everyone. Pro is the optional
step up for organizations that want longer retention, higher limits, MSP delegation,
and reliability commitments.
</p>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-8 text-[15px]">
<PlanCards
surface="marketing"
communityBadge={
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-[var(--lp-accent-soft)] text-[var(--lp-accent-ink)]">
Free forever
</span>
}
proBadge={
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700 border border-purple-200">
Coming soon
</span>
}
proCta={
<Link
href="/buy"
className="block w-full text-center text-sm font-semibold text-white bg-purple-600 rounded-lg px-4 py-2.5 hover:bg-purple-700 transition-colors"
>
How to get Pro
</Link>
}
/>
<p className="text-sm text-gray-600">
Pricing and availability for Pro will be announced. Community stays free.{" "}
For a feature-by-feature reference, see the{" "}
<a
href={`${DOCS_URL}/plans`}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
plans page in the documentation
</a>
.
</p>
</main>
<SiteFooter />
</div>
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ export default function robots(): MetadataRoute.Robots {
rules: [
{
userAgent: "*",
allow: ["/", "/about", "/get-started", "/privacy", "/terms"],
allow: ["/", "/about", "/buy", "/get-started", "/help", "/plans", "/privacy", "/terms"],
disallow: [
"/dashboard",
"/fleet-health",
@@ -4,6 +4,8 @@ import { useState } from "react";
import Link from "next/link";
import { useTenantConfig } from "../../TenantConfigContext";
import { trialDaysLeft } from "@/lib/edition";
import { PlanCards } from "@/components/plans/PlanCards";
import { SITE_URL } from "@/utils/config";
/**
* Self-service Pro trial switch. The Pro feature set is not finalized yet, so the
@@ -14,65 +16,13 @@ import { trialDaysLeft } from "@/lib/edition";
const TRIAL_SELF_SERVICE_ENABLED = false;
/**
* Retention baselines per plan tier. These describe the two PLANS on the comparison cards, so they
* must not depend on the viewer's current edition — they mirror the backend catalog
* (FeatureEntitlementCatalog: Community RetentionCapDays = 90, Pro = 365).
*/
const COMMUNITY_RETENTION_DAYS = 90;
const PRO_RETENTION_DAYS = 365;
function CheckIcon({ className }: { className: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);
}
function PlusIcon({ className }: { className: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
</svg>
);
}
function FeatureList({ features, checkClass, muted = false }: { features: string[]; checkClass: string; muted?: boolean }) {
return (
<ul className="space-y-2.5">
{features.map((f) => (
<li key={f} className={`flex items-start gap-2 text-sm ${muted ? "text-gray-600" : "text-gray-700"}`}>
<CheckIcon className={`w-4 h-4 mt-0.5 shrink-0 ${checkClass}`} />
<span>{f}</span>
</li>
))}
</ul>
);
}
/** The Pro delta: same layout as FeatureList but "+" bullets and emphasized text. */
function PlusList({ features }: { features: string[] }) {
return (
<ul className="space-y-2.5">
{features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm font-medium text-gray-900">
<PlusIcon className="w-4 h-4 mt-0.5 shrink-0 text-purple-600 dark:text-purple-400" />
<span>{f}</span>
</li>
))}
</ul>
);
}
/**
* Plan section: two side-by-side plan cards (Community and Pro). The tenant's current plan
* is highlighted so the edition is obvious at a glance; the Pro card teases what it adds.
* Plan section: the shared Community/Pro comparison cards (components/plans/PlanCards —
* also rendered on the public /plans page) with the tenant's current plan highlighted,
* plus the portal-only trial CTA and the purchase handoff.
*
* Dark mode: the tinted card surfaces use slash-opacity utilities (bg-purple-50/40), which the
* global `.dark .bg-*` override map in globals.css does NOT match — they are separate class tokens.
* Without the explicit `dark:` counterparts below the cards render as a washed-out light overlay on
* the dark page. Secondary copy uses text-gray-600 rather than -400/-500: both of those collapse to
* the same dim #64748b in dark mode, which fails contrast on a tinted card.
* The purchase link is an ABSOLUTE www URL on purpose: /buy is a public path, and
* HostRoutingGuard never bounces an authenticated user off the portal origin, so a
* relative link would try (and fail) to serve the marketing page on the portal host.
*/
export function SectionPlan() {
const { editionInfo, startTrial, startingTrial, user } = useTenantConfig();
@@ -84,31 +34,104 @@ export function SectionPlan() {
const canStartTrial =
editionInfo.trialAvailable && (user?.isTenantAdmin === true || user?.isGlobalAdmin === true);
// Features shared by both plans verbatim. Plan-specific items (retention, support tier) are NOT
// in here — repeating "90-day retention" on the Pro card would be factually wrong; their
// Pro counterparts live in proExtras instead.
const sharedFeatures = [
"Live session monitoring & progress portal",
"Full rules engine, including custom rules",
"Fleet analytics, notifications & diagnostics",
"AI integration (MCP) within usage limits",
];
const communityBadge = !isPro ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gray-900 text-white dark:bg-slate-200 dark:text-slate-900">
Current plan
</span>
) : undefined;
const communityFeatures = [
...sharedFeatures,
`${COMMUNITY_RETENTION_DAYS}-day data retention`,
"Community support (GitHub)",
];
const proBadge = isPro ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-purple-600 text-white">
{editionInfo.isTrial ? `Trial — ${daysLeft} day${daysLeft === 1 ? "" : "s"} left` : "Current plan"}
</span>
) : (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700 border border-purple-200">
Coming soon
</span>
);
const proExtras = [
`Extended data retention — ${PRO_RETENTION_DAYS} days (vs ${COMMUNITY_RETENTION_DAYS})`,
"Higher portal & agent API rate limits",
"Larger AI (MCP) usage quota",
"Delegated (MSP) administration across tenants",
"OOBE bootstrap sessions — run the agent already before MDM enrollment",
"Unrestricted Mode for advanced data collection (activated on request)",
"Reliability commitments & priority support",
];
// CTA — only meaningful while the tenant is on Community
const proCta = !isPro ? (
<>
{trialConsumed ? (
<p className="text-sm text-gray-600">
This tenant has already used its Pro trial. To move to Pro,{" "}
<a
href="https://github.com/okieselbach/AutopilotMonitor/issues"
target="_blank"
rel="noopener noreferrer"
className="text-purple-700 hover:underline"
>
get in touch
</a>
.
</p>
) : !TRIAL_SELF_SERVICE_ENABLED ? (
<button
type="button"
disabled
title="Available soon — the Pro trial opens once the feature set is finalized."
className="w-full text-sm font-medium text-white bg-purple-400 rounded-lg px-4 py-2.5 cursor-not-allowed opacity-70"
>
Start 30-day Pro trial — coming soon
</button>
) : canStartTrial && !editionInfo.contactEmailSet ? (
// Pro-requires-contact gate (backend enforces the same via 409
// ContactEmailRequired — this branch just makes the path obvious).
<div className="text-sm bg-amber-50 border border-amber-200 rounded-lg p-3 text-amber-900 dark:bg-amber-950/30 dark:border-amber-700/50 dark:text-amber-200">
<span className="font-medium">Pro requires a contact address.</span>{" "}
Set one under{" "}
<Link href="/settings/tenant/contact" className="font-medium text-purple-700 hover:underline dark:text-purple-300">
Contact
</Link>{" "}
so we can reach you about service or security matters — then start your trial here.
</div>
) : canStartTrial && !confirming ? (
<button
type="button"
onClick={() => setConfirming(true)}
className="w-full text-sm font-medium text-white bg-purple-600 rounded-lg px-4 py-2.5 hover:bg-purple-700 transition-colors"
>
Start 30-day Pro trial
</button>
) : canStartTrial && confirming ? (
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-600">One-time trial — start now?</span>
<button
type="button"
onClick={async () => {
const ok = await startTrial();
if (ok) setConfirming(false);
}}
disabled={startingTrial}
className="font-medium text-white bg-purple-600 rounded-lg px-3 py-1.5 hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{startingTrial ? "Starting…" : "Confirm"}
</button>
<button
type="button"
onClick={() => setConfirming(false)}
disabled={startingTrial}
className="text-gray-500 hover:text-gray-700"
>
Cancel
</button>
</div>
) : null}
<p className="mt-3 text-xs text-gray-600">
Curious how Pro will be sold?{" "}
<a
href={`${SITE_URL}/buy`}
target="_blank"
rel="noopener noreferrer"
className="text-purple-700 hover:underline dark:text-purple-300"
>
View purchase options
</a>
.
</p>
</>
) : undefined;
return (
<div className="bg-white rounded-lg shadow">
@@ -125,150 +148,14 @@ export function SectionPlan() {
</div>
<div className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Community card */}
<div
className={`rounded-xl border p-6 flex flex-col ${
!isPro
? "border-gray-800 ring-1 ring-gray-800 bg-gray-50/60 dark:border-slate-500 dark:ring-slate-500 dark:bg-slate-900/40"
: "border-gray-200"
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-lg font-semibold text-gray-900">Community</h3>
<p className="text-sm text-gray-600 mt-0.5">The full product, free</p>
</div>
{!isPro && (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gray-900 text-white dark:bg-slate-200 dark:text-slate-900">
Current plan
</span>
)}
</div>
<div className="mt-4 mb-5">
<span className="text-2xl font-bold text-gray-900">Free</span>
<span className="text-sm text-gray-600"> — and stays free</span>
</div>
<FeatureList features={communityFeatures} checkClass="text-emerald-500" />
</div>
{/* Pro card */}
<div
className={`rounded-xl border p-6 flex flex-col ${
isPro
? "border-purple-500 ring-1 ring-purple-500 bg-purple-50/40 dark:bg-purple-950/40"
: "border-purple-200 bg-purple-50/20 dark:bg-purple-950/20"
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-lg font-semibold text-purple-900">Pro</h3>
<p className="text-sm text-gray-600 mt-0.5">Higher limits, support & MSP</p>
</div>
{isPro ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-purple-600 text-white">
{editionInfo.isTrial ? `Trial — ${daysLeft} day${daysLeft === 1 ? "" : "s"} left` : "Current plan"}
</span>
) : (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700 border border-purple-200">
Coming soon
</span>
)}
</div>
<div className="mt-4 mb-5">
<span className="text-2xl font-bold text-purple-900">
{isPro ? "Active" : "Pricing TBA"}
</span>
{!isPro && <span className="text-sm text-gray-600"> — to be announced</span>}
</div>
<p className="text-xs font-medium uppercase tracking-wide text-gray-600 mb-2.5">
Everything in Community
</p>
<FeatureList features={sharedFeatures} checkClass="text-gray-500" muted />
<div className="flex items-center gap-3 my-4" aria-hidden="true">
<span className="h-px flex-1 bg-purple-200 dark:bg-purple-800" />
<span className="text-xs font-semibold uppercase tracking-wide text-purple-600 dark:text-purple-300">Plus</span>
<span className="h-px flex-1 bg-purple-200 dark:bg-purple-800" />
</div>
<PlusList features={proExtras} />
{/* CTA — only meaningful while the tenant is on Community */}
{!isPro && (
<div className="mt-auto pt-5">
{trialConsumed ? (
<p className="text-sm text-gray-600">
This tenant has already used its Pro trial. To move to Pro,{" "}
<a
href="https://github.com/okieselbach/AutopilotMonitor/issues"
target="_blank"
rel="noopener noreferrer"
className="text-purple-700 hover:underline"
>
get in touch
</a>
.
</p>
) : !TRIAL_SELF_SERVICE_ENABLED ? (
<button
type="button"
disabled
title="Available soon — the Pro trial opens once the feature set is finalized."
className="w-full text-sm font-medium text-white bg-purple-400 rounded-lg px-4 py-2.5 cursor-not-allowed opacity-70"
>
Start 30-day Pro trial — coming soon
</button>
) : canStartTrial && !editionInfo.contactEmailSet ? (
// Pro-requires-contact gate (backend enforces the same via 409
// ContactEmailRequired — this branch just makes the path obvious).
<div className="text-sm bg-amber-50 border border-amber-200 rounded-lg p-3 text-amber-900 dark:bg-amber-950/30 dark:border-amber-700/50 dark:text-amber-200">
<span className="font-medium">Pro requires a contact address.</span>{" "}
Set one under{" "}
<Link href="/settings/tenant/contact" className="font-medium text-purple-700 hover:underline dark:text-purple-300">
Contact
</Link>{" "}
so we can reach you about service or security matters — then start your trial here.
</div>
) : canStartTrial && !confirming ? (
<button
type="button"
onClick={() => setConfirming(true)}
className="w-full text-sm font-medium text-white bg-purple-600 rounded-lg px-4 py-2.5 hover:bg-purple-700 transition-colors"
>
Start 30-day Pro trial
</button>
) : canStartTrial && confirming ? (
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-600">One-time trial — start now?</span>
<button
type="button"
onClick={async () => {
const ok = await startTrial();
if (ok) setConfirming(false);
}}
disabled={startingTrial}
className="font-medium text-white bg-purple-600 rounded-lg px-3 py-1.5 hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{startingTrial ? "Starting…" : "Confirm"}
</button>
<button
type="button"
onClick={() => setConfirming(false)}
disabled={startingTrial}
className="text-gray-500 hover:text-gray-700"
>
Cancel
</button>
</div>
) : null}
</div>
)}
</div>
</div>
<PlanCards
surface="portal"
highlight={isPro ? "pro" : "community"}
communityBadge={communityBadge}
proBadge={proBadge}
proPrice={isPro ? <span className="text-2xl font-bold text-purple-900">Active</span> : undefined}
proCta={proCta}
/>
<p className="text-xs text-gray-600 mt-5">
{isPro && editionInfo.isTrial
@@ -34,6 +34,24 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/plans`,
lastModified: lastmod("/plans"),
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/buy`,
lastModified: lastmod("/buy"),
changeFrequency: "monthly",
priority: 0.6,
},
{
url: `${BASE_URL}/help`,
lastModified: lastmod("/help"),
changeFrequency: "monthly",
priority: 0.5,
},
{
url: `${BASE_URL}/privacy`,
lastModified: lastmod("/privacy"),
@@ -8,7 +8,7 @@ const LINK_COLUMNS = [
{ label: "The Story", href: "/#story" },
{ label: "Capabilities", href: "/#features" },
{ label: "Compare", href: "/#comparison" },
{ label: "Plans", href: `${DOCS_URL}/plans`, external: true },
{ label: "Plans", href: "/plans" },
],
},
{
@@ -1,5 +1,3 @@
import { DOCS_URL } from "@/utils/config";
export function FinalCta() {
return (
<section className="py-20 sm:py-24 px-6 border-t border-[var(--lp-line-soft)]">
@@ -19,9 +17,7 @@ export function FinalCta() {
<span className="block mt-0.5">
Need SLAs, support, or MSP delegation?{" "}
<a
href={`${DOCS_URL}/plans`}
target="_blank"
rel="noopener noreferrer"
href="/plans"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
There&apos;s a Pro plan
@@ -13,6 +13,7 @@ const NAV_LINKS = [
{ href: "/#story", label: "Product" },
{ href: "/#features", label: "Capabilities" },
{ href: "/#comparison", label: "Compare" },
{ href: "/plans", label: "Plans" },
{ href: DOCS_URL, label: "Docs", external: true },
];
@@ -0,0 +1,161 @@
import type { ReactNode } from "react";
import { communityFeatures, PRO_PRICE, proExtras, sharedFeatures } from "./planData";
/**
* Shared Community/Pro comparison cards — the ONE place the two plans are laid
* out. The portal Plan section and the public /plans page both render this;
* plan copy itself lives in planData.ts.
*
* Surfaces:
* - "portal": plain Tailwind palette + explicit `dark:` variants. The tinted
* card surfaces use slash-opacity utilities (bg-purple-50/40), which the
* global `.dark .bg-*` override map in globals.css does NOT match — they are
* separate class tokens. Without the explicit `dark:` counterparts the cards
* render as a washed-out light overlay on the dark page. Secondary copy uses
* text-gray-600 rather than -400/-500: both of those collapse to the same dim
* #64748b in dark mode, which fails contrast on a tinted card.
* - "marketing": renders inside the light-only `.landing-v2` shell, so no
* `dark:` variants (they key on `.dark` at <html> and would bleed through).
* The purple utilities used here are neutralized under `.dark .landing-v2`
* in globals.css, like the gray/white ones.
*/
function CheckIcon({ className }: { className: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);
}
function PlusIcon({ className }: { className: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
</svg>
);
}
function FeatureList({ features, checkClass, muted = false }: { features: string[]; checkClass: string; muted?: boolean }) {
return (
<ul className="space-y-2.5">
{features.map((f) => (
<li key={f} className={`flex items-start gap-2 text-sm ${muted ? "text-gray-600" : "text-gray-700"}`}>
<CheckIcon className={`w-4 h-4 mt-0.5 shrink-0 ${checkClass}`} />
<span>{f}</span>
</li>
))}
</ul>
);
}
/** The Pro delta: same layout as FeatureList but "+" bullets and emphasized text. */
function PlusList({ features, iconClass }: { features: string[]; iconClass: string }) {
return (
<ul className="space-y-2.5">
{features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm font-medium text-gray-900">
<PlusIcon className={`w-4 h-4 mt-0.5 shrink-0 ${iconClass}`} />
<span>{f}</span>
</li>
))}
</ul>
);
}
export interface PlanCardsProps {
surface: "marketing" | "portal";
/** Which card gets the "this is yours" highlight treatment (portal only). */
highlight?: "community" | "pro" | null;
communityBadge?: ReactNode;
proBadge?: ReactNode;
/** Overrides the Pro price line (portal shows "Active" while on Pro). */
proPrice?: ReactNode;
/** Rendered at the bottom of the Pro card — trial CTA on portal, buy link on marketing. */
proCta?: ReactNode;
}
export function PlanCards({ surface, highlight = null, communityBadge, proBadge, proPrice, proCta }: PlanCardsProps) {
const marketing = surface === "marketing";
const communityCardClass =
highlight === "community"
? "border-gray-800 ring-1 ring-gray-800 bg-gray-50/60 dark:border-slate-500 dark:ring-slate-500 dark:bg-slate-900/40"
: marketing
? "border-[var(--lp-line-soft)] bg-[var(--lp-surface)]"
: "border-gray-200";
const proCardClass =
highlight === "pro"
? "border-purple-500 ring-1 ring-purple-500 bg-purple-50/40 dark:bg-purple-950/40"
: marketing
? "border-purple-200 bg-purple-50/20"
: "border-purple-200 bg-purple-50/20 dark:bg-purple-950/20";
const plusIconClass = marketing ? "text-purple-600" : "text-purple-600 dark:text-purple-400";
const dividerClass = marketing ? "bg-purple-200" : "bg-purple-200 dark:bg-purple-800";
const dividerLabelClass = marketing ? "text-purple-600" : "text-purple-600 dark:text-purple-300";
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Community card */}
<div className={`rounded-xl border p-6 flex flex-col ${communityCardClass}`}>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-lg font-semibold text-gray-900">Community</h3>
<p className="text-sm text-gray-600 mt-0.5">The full product, free</p>
</div>
{communityBadge}
</div>
<div className="mt-4 mb-5">
<span className="text-2xl font-bold text-gray-900">Free</span>
<span className="text-sm text-gray-600"> — and stays free</span>
</div>
<FeatureList features={communityFeatures} checkClass="text-emerald-500" />
</div>
{/* Pro card */}
<div className={`rounded-xl border p-6 flex flex-col ${proCardClass}`}>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-lg font-semibold text-purple-900">Pro</h3>
<p className="text-sm text-gray-600 mt-0.5">Higher limits, support &amp; MSP</p>
</div>
{proBadge}
</div>
<div className="mt-4 mb-5">
{proPrice ?? (
PRO_PRICE ? (
<>
<span className="text-2xl font-bold text-purple-900">{PRO_PRICE.amount}</span>
<span className="text-sm text-gray-600"> {PRO_PRICE.suffix}</span>
</>
) : (
<>
<span className="text-2xl font-bold text-purple-900">Pricing</span>
<span className="text-sm text-gray-600"> — announced soon</span>
</>
)
)}
</div>
<p className="text-xs font-medium uppercase tracking-wide text-gray-600 mb-2.5">
Everything in Community
</p>
<FeatureList features={sharedFeatures} checkClass="text-gray-500" muted />
<div className="flex items-center gap-3 my-4" aria-hidden="true">
<span className={`h-px flex-1 ${dividerClass}`} />
<span className={`text-xs font-semibold uppercase tracking-wide ${dividerLabelClass}`}>Plus</span>
<span className={`h-px flex-1 ${dividerClass}`} />
</div>
<PlusList features={proExtras} iconClass={plusIconClass} />
{proCta && <div className="mt-auto pt-5">{proCta}</div>}
</div>
</div>
);
}
@@ -0,0 +1,47 @@
/**
* Single source of truth for the Community/Pro plan comparison. Rendered by
* PlanCards on both the public /plans page and the portal Plan section
* (settings/tenant/plan), so the two surfaces cannot drift apart.
*/
/**
* Retention baselines per plan tier. These describe the two PLANS on the comparison cards, so they
* must not depend on the viewer's current edition — they mirror the backend catalog
* (FeatureEntitlementCatalog: Community RetentionCapDays = 90, Pro = 365).
*/
export const COMMUNITY_RETENTION_DAYS = 90;
export const PRO_RETENTION_DAYS = 365;
// Features shared by both plans verbatim. Plan-specific items (retention, support tier) are NOT
// in here — repeating "90-day retention" on the Pro card would be factually wrong; their
// Pro counterparts live in proExtras instead.
export const sharedFeatures = [
"Live session monitoring & progress portal",
"Full rules engine, including custom rules",
"Fleet analytics, notifications & diagnostics",
"AI integration (MCP) within usage limits",
];
export const communityFeatures = [
...sharedFeatures,
`${COMMUNITY_RETENTION_DAYS}-day data retention`,
"Community support (GitHub)",
];
export const proExtras = [
`Extended data retention — ${PRO_RETENTION_DAYS} days (vs ${COMMUNITY_RETENTION_DAYS})`,
"Higher portal & agent API rate limits",
"Larger AI (MCP) usage quota",
"Delegated (MSP) administration across tenants",
"OOBE bootstrap sessions — run the agent already before MDM enrollment",
"Unrestricted Mode for advanced data collection (activated on request)",
"Reliability commitments & priority support",
];
/**
* Pro price. Stays null until pricing is announced — PlanCards then renders the
* "Pricing — announced soon" teaser. Once the price is final, set it here (e.g.
* `{ amount: "€X", suffix: "per device / month" }`) and every surface — landing
* and portal — updates in the same one-line change.
*/
export const PRO_PRICE: { amount: string; suffix: string } | null = null;
@@ -128,6 +128,8 @@ describe("hostRouting public/portal guard", () => {
// Prefix matching: subpaths and generated asset variants match…
expect(isPublicPath("/about")).toBe(true);
expect(isPublicPath("/help")).toBe(true);
expect(isPublicPath("/plans")).toBe(true);
expect(isPublicPath("/buy")).toBe(true);
expect(isPublicPath("/docs/setup")).toBe(true);
expect(isPublicPath("/icon-192.png")).toBe(true);
expect(isPublicPath("/opengraph-image.png")).toBe(true);
@@ -22,10 +22,12 @@ import { PORTAL_URL, SITE_URL } from "@/utils/config";
*/
export const PUBLIC_PATH_PREFIXES = [
"/about",
"/buy",
"/changelog",
"/docs",
"/get-started",
"/help",
"/plans",
"/privacy",
"/terms",
"/roadmap",
@@ -18,6 +18,9 @@ const OUTPUT_FILE = path.join(WEB_ROOT, "utils/page-lastmod.generated.ts");
const PAGE_MAP = {
"/": ["app/page.tsx"],
"/about": ["app/about/page.tsx"],
"/buy": ["app/buy/page.tsx"],
"/help": ["app/help/page.tsx"],
"/plans": ["app/plans/page.tsx", "components/plans/planData.ts"],
"/roadmap": ["app/roadmap/page.tsx"],
"/changelog": ["app/changelog/page.tsx"],
"/privacy": ["app/privacy/page.tsx"],