commercial: give the Pro plan card a price, groups and a clear action

The Pro comparison card on Plans & Billing was a half-width box of small grey text: no price, every feature at the same weight, and a small outline link as the only way to buy. It now has a header with the plan name, a one-line tagline, the price and a primary View plans button, then the features in three labelled groups with check marks: for your team, for larger estates, and Patrol and history.
This commit is contained in:
rcourtman
2026-10-21 11:36:04 +01:00
parent f8dc940088
commit 49d437331e
9 changed files with 163 additions and 79 deletions
@@ -3405,7 +3405,12 @@ artificial feature ladder. Pro is the plan for small teams and larger estates
probes, and commercial support, with Patrol modes, Patrol fixes, and 90-day
history following. Paid features for an organisation read as normal, while
paid features an individual needs for their own setup read as a withheld free
version (issue #1409), so Pro copy must not lead with individual-only value.
version (issue #1409), so Pro copy must not lead with individual-only value. The
in-app comparison card on Plans & Billing presents that offer as one plan
card: plan name, tagline, price and the primary purchase action in its
header, then features grouped under "For your team", "For larger estates" and
"Patrol and history". Groups and tagline come from
`frontend-modern/src/utils/selfHostedPlans.ts`, not from component-local copy.
Relay was retired on 2026-09-29 and existing Relay subscribers carry Pro
entitlements, including Pulse Mobile pairing until the app retires. Retired Pro trial acquisition
copy and dormant Cloud checkout catalogs must not reappear in the ordinary
@@ -1,4 +1,5 @@
import { Component, For, Show } from 'solid-js';
import CheckIcon from 'lucide-solid/icons/check';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import { Button, ButtonLink } from '@/components/shared/Button';
import { InlineNotice, type InlineNoticeTone } from '@/components/shared/InlineNotice';
@@ -8,6 +9,7 @@ import {
getLicenseStatusLoadingState,
getNoActiveSelfHostedActivationState,
type SelfHostedPatrolControlActionIntent,
type SelfHostedPlanComparisonCardPresentation,
} from '@/utils/licensePresentation';
import { isExternalUpgradeHref, type UpgradeDestination } from '@/utils/upgradeNavigation';
import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from './selfHostedBillingPresentation';
@@ -70,11 +72,7 @@ interface ProLicensePlanSectionProps {
loading: boolean;
onReload: () => void;
planComparisonSummary: {
cards: Array<{
title: string;
body: string;
highlights: string[];
}>;
cards: SelfHostedPlanComparisonCardPresentation[];
action: {
label: string;
destination: UpgradeDestination;
@@ -349,45 +347,61 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
)}
</Show>
<Show when={props.planComparisonSummary.cards.length > 0}>
<div class="mb-4 rounded-md border border-border bg-surface p-4">
<p class="text-sm font-medium text-base-content">
{SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonSectionTitle}
</p>
<div class="mt-3 grid gap-3 lg:grid-cols-2">
<For each={props.planComparisonSummary.cards}>
{(card) => (
<div class="rounded-md border border-border bg-surface-alt p-3">
<p class="text-sm font-medium text-base-content">{card.title}</p>
<p class="mt-1 text-xs text-muted">{card.body}</p>
<ul class="mt-3 grid gap-2">
<For each={card.highlights}>
{(feature) => (
<li class="text-xs text-base-content flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-primary"></span>
{feature}
</li>
)}
</For>
</ul>
<For each={props.planComparisonSummary.cards}>
{(card) => (
<section
class="mb-4 overflow-hidden rounded-lg border border-blue-200 bg-surface dark:border-blue-900"
aria-label={`${card.title} plan`}
>
<div class="flex flex-col gap-4 border-b border-blue-100 bg-blue-50 p-5 sm:flex-row sm:items-start sm:justify-between dark:border-blue-900 dark:bg-blue-950/30">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-wide text-blue-700 dark:text-blue-300">
{SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonSectionTitle}
</p>
<h3 class="mt-1 text-lg font-semibold text-base-content">{card.title}</h3>
<p class="mt-1 text-sm text-muted">{card.tagline}</p>
</div>
)}
</For>
</div>
<div class="flex flex-wrap items-center gap-2">
<Show when={props.planComparisonSummary.action}>
{(action) => (
<UpgradeButtonLink
variant="outline"
size="settingsActionXs"
class="mt-4 gap-1"
destination={action().destination}
>
{action().label}
</UpgradeButtonLink>
)}
</Show>
</div>
</div>
<div class="flex shrink-0 flex-col gap-3 sm:items-end sm:text-right">
<div>
<p class="text-2xl font-semibold text-base-content">{card.price}</p>
<p class="text-xs text-muted">{card.priceNote}</p>
</div>
<Show when={props.planComparisonSummary.action}>
{(action) => (
<UpgradeButtonLink variant="primary" destination={action().destination}>
{action().label}
</UpgradeButtonLink>
)}
</Show>
</div>
</div>
<div class="grid gap-5 p-5 sm:grid-cols-3">
<For each={card.groups}>
{(group) => (
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-wide text-muted">
{group.title}
</p>
<ul class="mt-2 space-y-2">
<For each={group.items}>
{(item) => (
<li class="flex gap-2 text-sm text-base-content">
<CheckIcon
class="mt-0.5 h-4 w-4 shrink-0 text-blue-600 dark:text-blue-400"
aria-hidden="true"
/>
<span>{item}</span>
</li>
)}
</For>
</ul>
</div>
)}
</For>
</div>
</section>
)}
</For>
</Show>
<Show when={props.commercialMigrationNotice}>
{(notice) => (
@@ -271,7 +271,7 @@ describe('ProLicensePanel', () => {
expect(screen.queryByText('Select a plan')).not.toBeInTheDocument();
expect(screen.getByText('Available plans')).toBeInTheDocument();
expect(screen.queryByText('Relay plan')).not.toBeInTheDocument();
expect(screen.getByText('Pulse Pro plan')).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Pulse Pro plan' })).toBeInTheDocument();
expect(screen.getAllByRole('link', { name: 'View plans' }).length).toBeGreaterThan(0);
});
@@ -308,18 +308,24 @@ describe('ProLicensePanel', () => {
expect(screen.getByText('Available plans')).toBeInTheDocument();
// Relay was retired on 2026-09-29: Community sees only the Pro card.
expect(screen.queryByText('Relay plan')).not.toBeInTheDocument();
expect(screen.getByText('Pulse Pro plan')).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Pulse Pro plan' })).toBeInTheDocument();
expect(screen.queryByText(/remote web access/i)).not.toBeInTheDocument();
// Team and estate value leads; Patrol and history follow.
const highlightTexts = [
'Team controls: 3 named seats, RBAC, and audit logging',
'For your team',
'3 named team seats',
'Roles and permissions (RBAC)',
'Audit logging',
'For larger estates',
'Reporting and agent profiles',
'External probes check your services from another site',
'External probes from another site',
'Commercial support, typically within two business days',
'Patrol and history',
'Patrol modes: Ask first, Safe auto-fix, or Autopilot',
'Patrol investigates issues, applies safe fixes, and verifies the result',
'90-day metric history',
'Patrol fixes issues and verifies the result',
'90-day metric history (Community keeps 7 days)',
];
expect(screen.getByText('$79/year')).toBeInTheDocument();
const rendered = highlightTexts.map((text) => screen.getByText(text));
for (let i = 1; i < rendered.length; i += 1) {
expect(
@@ -908,7 +914,7 @@ describe('ProLicensePanel', () => {
).toBeInTheDocument();
expect(screen.queryByText('Available plans')).not.toBeInTheDocument();
expect(screen.queryByText('Relay plan')).not.toBeInTheDocument();
expect(screen.queryByText('Pulse Pro plan')).not.toBeInTheDocument();
expect(screen.queryByRole('region', { name: 'Pulse Pro plan' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument();
expect(screen.getByText('Relay status')).toBeInTheDocument();
expect(screen.getAllByText('Pulse Mobile pairing and push').length).toBeGreaterThan(0);
@@ -47,7 +47,15 @@ describe('ProLicensePlanSection retired trial acquisition', () => {
<ProLicensePlanSection
{...baseProps()}
planComparisonSummary={{
cards: [{ title: 'Pro', body: 'Everything', highlights: [] }],
cards: [
{
title: 'Pulse Pro',
tagline: 'For small teams and larger estates',
price: '$79/year',
priceNote: 'or $8.99/month',
groups: [],
},
],
action: {
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonActionLabel,
destination: resolveUpgradeDestination(
@@ -433,7 +433,7 @@ describe('getSelfHostedPlanComparisonPresentation - edge cases', () => {
it('shows only the Pro card for null / undefined entitlements', () => {
const result = getSelfHostedPlanComparisonPresentation({ entitlements: null });
expect(result.cards).toHaveLength(1);
expect(result.cards[0].title).toBe('Pulse Pro plan');
expect(result.cards[0].title).toBe('Pulse Pro');
});
it('shows only the Pro card for the community tier', () => {
@@ -446,7 +446,7 @@ describe('getSelfHostedPlanComparisonPresentation - edge cases', () => {
upgrade_reasons: [],
},
});
expect(result.cards.map((c) => c.title)).toEqual(['Pulse Pro plan']);
expect(result.cards.map((c) => c.title)).toEqual(['Pulse Pro']);
});
it('returns no cards for an unrecognized tier', () => {
@@ -901,16 +901,31 @@ describe('licensePresentation', () => {
).toEqual({
cards: [
{
title: 'Pulse Pro plan',
body: 'For small teams and larger estates: team access with audit logging, reporting, checks from outside your network, and support, plus Patrol fixes and 90-day history.',
highlights: [
'Team controls: 3 named seats, RBAC, and audit logging',
'Reporting and agent profiles',
'External probes check your services from another site',
'Commercial support, typically within two business days',
'Patrol modes: Ask first, Safe auto-fix, or Autopilot',
'Patrol investigates issues, applies safe fixes, and verifies the result',
'90-day metric history',
title: 'Pulse Pro',
tagline: 'For small teams and larger estates',
price: '$79/year',
priceNote: 'or $8.99/month',
groups: [
{
title: 'For your team',
items: ['3 named team seats', 'Roles and permissions (RBAC)', 'Audit logging'],
},
{
title: 'For larger estates',
items: [
'Reporting and agent profiles',
'External probes from another site',
'Commercial support, typically within two business days',
],
},
{
title: 'Patrol and history',
items: [
'Patrol modes: Ask first, Safe auto-fix, or Autopilot',
'Patrol fixes issues and verifies the result',
'90-day metric history (Community keeps 7 days)',
],
},
],
},
],
@@ -171,8 +171,10 @@ export type SelfHostedActivationSuccessSource = 'manual' | 'purchase';
export interface SelfHostedPlanComparisonCardPresentation {
title: string;
body: string;
highlights: string[];
tagline: string;
price: string;
priceNote: string;
groups: { title: string; items: string[] }[];
}
export interface SelfHostedPlanComparisonPresentation {
@@ -628,10 +630,18 @@ export const getSelfHostedPlanComparisonPresentation = ({
if (!definition) {
return null;
}
const groups = definition.comparisonGroups?.length
? definition.comparisonGroups.map((group) => ({
title: group.title,
items: [...group.items],
}))
: [{ title: 'Included', items: getSelfHostedPlanComparisonHighlights(definition) }];
return {
title: `${getSelfHostedPlanLabel(tier)} plan`,
body: definition.comparisonSummary,
highlights: getSelfHostedPlanComparisonHighlights(definition),
title: getSelfHostedPlanLabel(tier),
tagline: definition.comparisonTagline ?? definition.comparisonSummary,
price: definition.price,
priceNote: definition.subline,
groups,
};
})
.filter((card): card is SelfHostedPlanComparisonCardPresentation => card !== null),
+35 -9
View File
@@ -17,8 +17,40 @@ export interface SelfHostedPlanDefinition {
includedExtras: readonly string[];
comparisonSummary: string;
highlights: readonly string[];
// Short line under the plan name on the in-app comparison card.
comparisonTagline?: string;
// Feature groups for the in-app comparison card; falls back to highlights.
comparisonGroups?: readonly SelfHostedPlanComparisonGroup[];
}
export interface SelfHostedPlanComparisonGroup {
title: string;
items: readonly string[];
}
const PRO_COMPARISON_GROUPS: readonly SelfHostedPlanComparisonGroup[] = [
{
title: 'For your team',
items: ['3 named team seats', 'Roles and permissions (RBAC)', 'Audit logging'],
},
{
title: 'For larger estates',
items: [
'Reporting and agent profiles',
'External probes from another site',
'Commercial support, typically within two business days',
],
},
{
title: 'Patrol and history',
items: [
'Patrol modes: Ask first, Safe auto-fix, or Autopilot',
'Patrol fixes issues and verifies the result',
'90-day metric history (Community keeps 7 days)',
],
},
];
export interface SelfHostedFeatureRow {
key: string;
name: string;
@@ -155,15 +187,9 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] =
// features lead, Patrol autonomy and longer history follow.
comparisonSummary:
'For small teams and larger estates: team access with audit logging, reporting, checks from outside your network, and support, plus Patrol fixes and 90-day history.',
highlights: [
'Team controls: 3 named seats, RBAC, and audit logging',
'Reporting and agent profiles',
'External probes check your services from another site',
'Commercial support, typically within two business days',
'Patrol modes: Ask first, Safe auto-fix, or Autopilot',
'Patrol investigates issues, applies safe fixes, and verifies the result',
'90-day metric history',
],
comparisonTagline: 'For small teams and larger estates',
comparisonGroups: PRO_COMPARISON_GROUPS,
highlights: PRO_COMPARISON_GROUPS.flatMap((group) => group.items),
},
] as const;
@@ -123,7 +123,7 @@ test.describe.serial('Self-hosted paid prompt visibility', () => {
await expect(page.getByRole('heading', { name: 'Plans & Billing' }).first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Plans & Billing' })).toBeVisible();
await expect(page.getByText('Pulse Pro plan')).toBeVisible();
await expect(page.getByRole('region', { name: 'Pulse Pro plan' })).toBeVisible();
await expect(page.getByRole('link', { name: 'View plans' }).first()).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('free-explicit-billing-route.png'),