mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Canonicalize cloud pricing display contract
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user