mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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 & Support
|
||||
</Link>{" "}
|
||||
page — you'll talk directly to the person building the platform. And if you
|
||||
haven'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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 & 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"],
|
||||
|
||||
Reference in New Issue
Block a user