Canonicalize cloud pricing display contract

This commit is contained in:
rcourtman
2026-03-26 22:57:13 +00:00
parent 3287150c5e
commit dc61333e57
7 changed files with 108 additions and 21 deletions
@@ -503,6 +503,11 @@ while `frontend-modern/src/pages/CloudPricing.tsx`,
`frontend-modern/src/pages/HostedSignup.tsx`, `frontend-modern/src/pages/PricingV6.tsx`,
and the self-hosted billing settings surfaces must consume those shared owners
instead of redefining retail plan facts or counted-unit policy locally.
That shared ownership also includes display-ready price semantics. Monthly
headline price, founding-rate override, compare-at strike-through copy, and
annual summary text must come from the shared plan-definition owners rather
than page-local string parsing or hardcoded retail amounts inside hosted
pricing/signup screens.
That same counted-unit boundary also owns the disclosure rule for retail copy:
default billing and pricing surfaces should use concise monitored-system copy,
while the full counted-unit definition appears only behind explicit disclosure
+17 -8
View File
@@ -4,7 +4,11 @@ import { Card } from '@/components/shared/Card';
import { PageHeader } from '@/components/shared/PageHeader';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import { onMount } from 'solid-js';
import { CLOUD_PLAN_DEFINITIONS, type CloudPlanDefinition } from '@/utils/cloudPlans';
import {
CLOUD_PLAN_DEFINITIONS,
getCloudPlanPricePresentation,
type CloudPlanDefinition,
} from '@/utils/cloudPlans';
const INCLUDED_IN_ALL = [
'All Pro features',
@@ -17,6 +21,7 @@ const INCLUDED_IN_ALL = [
function CloudTierCard(props: { tier: CloudPlanDefinition }) {
const t = props.tier;
const price = getCloudPlanPricePresentation(t);
return (
<Card
@@ -37,19 +42,23 @@ function CloudTierCard(props: { tier: CloudPlanDefinition }) {
<h2 class="text-lg font-semibold text-base-content">{t.name}</h2>
<Show when={t.foundingPrice}>
<Show when={price.compareAtMonthlyPrice}>
<div class="mt-2 text-2xl font-semibold tracking-tight text-amber-600 dark:text-amber-400">
$19<span class="text-base font-normal text-muted">/month</span>
{price.monthlyPrice}
<span class="text-base font-normal text-muted">{price.cadence}</span>
</div>
<div class="text-sm text-muted line-through">
{price.compareAtMonthlyPrice}
{price.cadence}
</div>
<div class="text-sm text-muted line-through">{t.price}</div>
</Show>
<Show when={!t.foundingPrice}>
<Show when={!price.compareAtMonthlyPrice}>
<div class="mt-2 text-3xl font-semibold tracking-tight text-base-content">
{t.price.replace('/month', '')}
<span class="text-base font-normal text-muted">/month</span>
{price.monthlyPrice}
<span class="text-base font-normal text-muted">{price.cadence}</span>
</div>
</Show>
<div class="mt-1 text-sm text-muted">{t.subline}</div>
<div class="mt-1 text-sm text-muted">{price.annualSummary}</div>
<dl class="mt-4 space-y-3 text-sm text-base-content">
<div class="flex items-center justify-between gap-3">
+15 -3
View File
@@ -5,7 +5,7 @@ import { PageHeader } from '@/components/shared/PageHeader';
import { HostedSignupAPI, type HostedAPIError } from '@/api/hostedSignup';
import { getUpgradeActionUrlOrFallback } from '@/stores/license';
import { logger } from '@/utils/logger';
import { getCloudPlanForTier } from '@/utils/cloudPlans';
import { getCloudPlanForTier, getCloudPlanPricePresentation } from '@/utils/cloudPlans';
type SignupStatus = 'idle' | 'submitting' | 'success' | 'unavailable' | 'error';
@@ -50,6 +50,7 @@ export default function HostedSignup() {
const params = new URLSearchParams(location.search);
return getCloudPlanForTier(params.get('tier'));
});
const selectedPlanPrice = createMemo(() => getCloudPlanPricePresentation(selectedPlan()));
const cloudPortalURL = createMemo(() => getUpgradeActionUrlOrFallback('cloud'));
const canSubmit = createMemo(() => {
@@ -212,9 +213,20 @@ export default function HostedSignup() {
<div class="flex items-baseline justify-between gap-3">
<div>
<p class="text-sm font-semibold text-base-content">{selectedPlan().name}</p>
<p class="text-xs text-muted">{selectedPlan().subline}</p>
<p class="text-xs text-muted">{selectedPlanPrice().annualSummary}</p>
</div>
<div class="text-right">
<p class="text-sm font-semibold text-base-content">
{selectedPlanPrice().monthlyPrice}
{selectedPlanPrice().cadence}
</p>
<Show when={selectedPlanPrice().compareAtMonthlyPrice}>
<p class="text-xs text-muted line-through">
{selectedPlanPrice().compareAtMonthlyPrice}
{selectedPlanPrice().cadence}
</p>
</Show>
</div>
<p class="text-sm font-semibold text-base-content">{selectedPlan().price}</p>
</div>
<dl class="mt-3 grid grid-cols-2 gap-3 text-sm text-base-content">
<div>
@@ -24,6 +24,8 @@ describe('CloudPricing', () => {
'href',
'/cloud/signup?tier=starter',
);
expect(screen.getByText('$19')).toBeInTheDocument();
expect(screen.getByText('$29/month')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Choose Power' })).toHaveAttribute(
'href',
'/cloud/signup?tier=power',
@@ -81,4 +81,19 @@ describe('HostedSignup', () => {
});
});
});
it('renders signup plan pricing from the shared cloud pricing contract', async () => {
window.history.replaceState({}, '', '/cloud/signup?tier=starter');
render(() => (
<Router>
<Route path="/cloud/signup" component={HostedSignup} />
</Router>
));
expect(await screen.findByText('Plan')).toBeInTheDocument();
expect(screen.getByText('$19/month')).toBeInTheDocument();
expect(screen.getByText('$29/month')).toBeInTheDocument();
expect(screen.getByText('or $249/year (save 29%)')).toBeInTheDocument();
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import {
CLOUD_PLAN_BY_TIER,
getCloudPlanPricePresentation,
} from '@/utils/cloudPlans';
describe('cloudPlans', () => {
it('keeps founding-rate display in the shared cloud pricing contract', () => {
expect(getCloudPlanPricePresentation(CLOUD_PLAN_BY_TIER.starter)).toEqual({
monthlyPrice: '$19',
cadence: '/month',
annualSummary: 'or $249/year (save 29%)',
compareAtMonthlyPrice: '$29',
});
});
it('keeps non-founding tiers on their standard monthly display', () => {
expect(getCloudPlanPricePresentation(CLOUD_PLAN_BY_TIER.power)).toEqual({
monthlyPrice: '$49',
cadence: '/month',
annualSummary: 'or $449/year (save 24%)',
compareAtMonthlyPrice: undefined,
});
});
});
+28 -10
View File
@@ -4,14 +4,21 @@ export interface CloudPlanDefinition {
tier: CloudTierKey;
planVersion: string;
name: string;
price: string;
subline: string;
monthlyPrice: string;
annualSummary: string;
monitoredSystems: number;
support: 'Community' | 'Priority';
foundingPrice?: string;
foundingMonthlyPrice?: string;
highlighted?: boolean;
}
export interface CloudPlanPricePresentation {
monthlyPrice: string;
cadence: '/month';
annualSummary: string;
compareAtMonthlyPrice?: string;
}
export const DEFAULT_CLOUD_TIER: CloudTierKey = 'starter';
export const CLOUD_PLAN_DEFINITIONS: readonly CloudPlanDefinition[] = [
@@ -19,19 +26,19 @@ export const CLOUD_PLAN_DEFINITIONS: readonly CloudPlanDefinition[] = [
tier: 'starter',
planVersion: 'cloud_starter',
name: 'Starter',
price: '$29/month',
subline: 'or $249/year (save 29%)',
monthlyPrice: '$29',
annualSummary: 'or $249/year (save 29%)',
monitoredSystems: 10,
support: 'Community',
foundingPrice: '$19/month',
foundingMonthlyPrice: '$19',
highlighted: true,
},
{
tier: 'power',
planVersion: 'cloud_power',
name: 'Power',
price: '$49/month',
subline: 'or $449/year (save 24%)',
monthlyPrice: '$49',
annualSummary: 'or $449/year (save 24%)',
monitoredSystems: 30,
support: 'Priority',
},
@@ -39,8 +46,8 @@ export const CLOUD_PLAN_DEFINITIONS: readonly CloudPlanDefinition[] = [
tier: 'max',
planVersion: 'cloud_max',
name: 'Max',
price: '$79/month',
subline: 'or $699/year (save 26%)',
monthlyPrice: '$79',
annualSummary: 'or $699/year (save 26%)',
monitoredSystems: 75,
support: 'Priority',
},
@@ -77,3 +84,14 @@ export function parseCloudTier(value?: string | null): CloudTierKey {
export function getCloudPlanForTier(value?: string | null): CloudPlanDefinition {
return CLOUD_PLAN_BY_TIER[parseCloudTier(value)];
}
export function getCloudPlanPricePresentation(
plan: CloudPlanDefinition,
): CloudPlanPricePresentation {
return {
monthlyPrice: plan.foundingMonthlyPrice || plan.monthlyPrice,
cadence: '/month',
annualSummary: plan.annualSummary,
compareAtMonthlyPrice: plan.foundingMonthlyPrice ? plan.monthlyPrice : undefined,
};
}