mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
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:
@@ -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);
|
||||
|
||||
+9
-1
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user