From 49d437331ea8bed816c01edea961fa6588136ed2 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:36:04 +0100 Subject: [PATCH] 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. --- .../v6/internal/subsystems/cloud-paid.md | 7 +- .../Settings/ProLicensePlanSection.tsx | 100 ++++++++++-------- .../__tests__/ProLicensePanel.test.tsx | 20 ++-- .../ProLicensePlanSectionTrial.test.tsx | 10 +- .../licensePresentation.coverage.test.ts | 4 +- .../__tests__/licensePresentation.test.ts | 35 ++++-- .../src/utils/licensePresentation.ts | 20 +++- frontend-modern/src/utils/selfHostedPlans.ts | 44 ++++++-- ...58-self-hosted-trial-rate-limit-ui.spec.ts | 2 +- 9 files changed, 163 insertions(+), 79 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index d624e210de..94656e76c6 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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 diff --git a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx index f434f12a99..8d38494eff 100644 --- a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx @@ -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 = (pro )} 0}> -
-

- {SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonSectionTitle} -

-
- - {(card) => ( -
-

{card.title}

-

{card.body}

-
    - - {(feature) => ( -
  • - - {feature} -
  • - )} -
    -
+ + {(card) => ( +
+
+
+

+ {SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonSectionTitle} +

+

{card.title}

+

{card.tagline}

- )} - -
-
- - {(action) => ( - - {action().label} - - )} - -
-
+
+
+

{card.price}

+

{card.priceNote}

+
+ + {(action) => ( + + {action().label} + + )} + +
+
+
+ + {(group) => ( +
+

+ {group.title} +

+
    + + {(item) => ( +
  • +
  • + )} +
    +
+
+ )} +
+
+ + )} + {(notice) => ( diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 605a193395..7fcba663e6 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -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); diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePlanSectionTrial.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePlanSectionTrial.test.tsx index 503c1ae18b..6d10b214cf 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePlanSectionTrial.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePlanSectionTrial.test.tsx @@ -47,7 +47,15 @@ describe('ProLicensePlanSection retired trial acquisition', () => { { 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', () => { diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index cba576d54f..828099ea0a 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -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)', + ], + }, ], }, ], diff --git a/frontend-modern/src/utils/licensePresentation.ts b/frontend-modern/src/utils/licensePresentation.ts index d1d3a3bfcb..bdf104cb75 100644 --- a/frontend-modern/src/utils/licensePresentation.ts +++ b/frontend-modern/src/utils/licensePresentation.ts @@ -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), diff --git a/frontend-modern/src/utils/selfHostedPlans.ts b/frontend-modern/src/utils/selfHostedPlans.ts index 9de8d6f1b0..f6f793a541 100644 --- a/frontend-modern/src/utils/selfHostedPlans.ts +++ b/frontend-modern/src/utils/selfHostedPlans.ts @@ -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; diff --git a/tests/integration/tests/58-self-hosted-trial-rate-limit-ui.spec.ts b/tests/integration/tests/58-self-hosted-trial-rate-limit-ui.spec.ts index dc288f937d..701e1907a8 100644 --- a/tests/integration/tests/58-self-hosted-trial-rate-limit-ui.spec.ts +++ b/tests/integration/tests/58-self-hosted-trial-rate-limit-ui.spec.ts @@ -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'),