Unify pricing trial CTA handling

This commit is contained in:
rcourtman
2026-03-25 10:40:55 +00:00
parent e04f836955
commit a86338f787
7 changed files with 120 additions and 30 deletions
@@ -243,6 +243,11 @@ success-notification, and canonical denial handling through
`frontend-modern/src/utils/trialStartAction.ts` instead of carrying local
`startProTrial()` redirect/error branches that drift from backend commercial
truth.
That same rule also covers the self-hosted pricing page in
`frontend-modern/src/pages/PricingV6.tsx`: pricing CTA state may choose when to
switch from trial to upgrade presentation, but the actual hosted handoff and
backend denial classification must still flow through the shared trial-start
owner instead of parsing raw trial-start status codes inline.
The hosted trial handoff page is part of that same boundary as well. It may
still use a secure hosted Stripe-backed session internally, but the customer
copy must present the flow as starting a trial for the originating Pulse
+15 -17
View File
@@ -14,11 +14,10 @@ import {
entitlements,
getUpgradeActionUrlOrFallback,
loadLicenseStatus,
startProTrial,
} from '@/stores/license';
import { showToast } from '@/utils/toast';
import { logger } from '@/utils/logger';
import { getTrialStartErrorMessage } from '@/utils/upgradePresentation';
import { getTrialStartErrorKind } from '@/utils/upgradePresentation';
import {
SELF_HOSTED_FEATURE_ROWS,
SELF_HOSTED_PLAN_BY_TIER,
@@ -26,6 +25,7 @@ import {
type SelfHostedPlanDefinition,
type SelfHostedTierKey,
} from '@/utils/selfHostedPlans';
import { runStartProTrialAction } from '@/utils/trialStartAction';
// ---------------------------------------------------------------------------
// Shared sub-components
@@ -181,22 +181,20 @@ export default function PricingV6() {
setStartingTrial(true);
try {
const result = await startProTrial();
if (result.outcome === 'redirect') {
if (typeof window !== 'undefined') {
window.location.href = result.actionUrl;
}
return;
const outcome = await runStartProTrialAction({
successMessage: 'Pro trial started (14 days).',
showSuccess: (message) => showToast('success', message),
showError: setTrialMessage,
onError: (error) => {
if (getTrialStartErrorKind(error) === 'already_used') {
setTrialCtaMode('upgrade');
}
logger.warn('[PricingV6] Trial start request failed', error);
},
});
if (outcome === 'activated') {
await loadLicenseStatus(true);
}
showToast('success', 'Pro trial started (14 days).');
await loadLicenseStatus(true);
} catch (error) {
const trialError = error as { code?: string } | null;
if (trialError?.code === 'trial_already_used') {
setTrialCtaMode('upgrade');
}
logger.warn('[PricingV6] Trial start request failed', error);
setTrialMessage(getTrialStartErrorMessage(error));
} finally {
setStartingTrial(false);
}
@@ -1,10 +1,19 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@solidjs/testing-library';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import type { JSX } from 'solid-js';
import PricingV6 from '../PricingV6';
import pricingV6Source from '../PricingV6.tsx?raw';
const entitlementsState = {
subscription_state: 'expired',
tier: 'free',
trial_eligible: false,
};
const startProTrialMock = vi.fn();
const loadLicenseStatusMock = vi.fn().mockResolvedValue(undefined);
const showToastMock = vi.fn();
vi.mock('@/components/shared/Card', () => ({
Card: (props: { children?: JSX.Element }) => <div>{props.children}</div>,
}));
@@ -28,14 +37,10 @@ vi.mock('@/components/shared/PageHeader', () => ({
}));
vi.mock('@/stores/license', () => ({
entitlements: () => ({
subscription_state: 'expired',
tier: 'free',
trial_eligible: false,
}),
entitlements: () => entitlementsState,
getUpgradeActionUrlOrFallback: (tier: string) => `/${tier}`,
loadLicenseStatus: vi.fn().mockResolvedValue(undefined),
startProTrial: vi.fn(),
loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args),
startProTrial: (...args: unknown[]) => startProTrialMock(...args),
}));
vi.mock('@/utils/upgradeMetrics', () => ({
@@ -43,7 +48,7 @@ vi.mock('@/utils/upgradeMetrics', () => ({
}));
vi.mock('@/utils/toast', () => ({
showToast: vi.fn(),
showToast: (...args: unknown[]) => showToastMock(...args),
}));
vi.mock('@/utils/logger', () => ({
@@ -54,6 +59,16 @@ vi.mock('@/utils/logger', () => ({
}));
describe('PricingV6', () => {
beforeEach(() => {
entitlementsState.subscription_state = 'expired';
entitlementsState.tier = 'free';
entitlementsState.trial_eligible = false;
startProTrialMock.mockReset();
loadLicenseStatusMock.mockReset();
loadLicenseStatusMock.mockResolvedValue(undefined);
showToastMock.mockReset();
});
it('renders self-hosted plan tiers from the shared pricing model', () => {
render(() => <PricingV6 />);
@@ -74,8 +89,32 @@ describe('PricingV6', () => {
it('imports the shared self-hosted pricing model instead of redefining it locally', () => {
expect(pricingV6Source).toContain("@/utils/selfHostedPlans");
expect(pricingV6Source).toContain("@/utils/upgradePresentation");
expect(pricingV6Source).toContain("@/utils/trialStartAction");
expect(pricingV6Source).not.toContain('const TIERS =');
expect(pricingV6Source).not.toContain('const FEATURE_ROWS');
expect(pricingV6Source).not.toContain("setTrialMessage('Trial already used.')");
expect(pricingV6Source).not.toContain('startProTrial()');
});
it('switches Pro pricing CTA to upgrade when trial is already used', async () => {
entitlementsState.trial_eligible = true;
startProTrialMock.mockRejectedValue({
status: 409,
code: 'trial_already_used',
message: 'Trial already used',
});
render(() => <PricingV6 />);
fireEvent.click(screen.getAllByRole('button', { name: 'Start Free 14-day Trial' })[0]);
await waitFor(() => {
expect(screen.getByText('Trial already used')).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: 'Upgrade to Pro' })).toHaveAttribute(
'href',
'/upgrade',
);
});
});
@@ -76,4 +76,26 @@ describe('runStartProTrialAction', () => {
expect(showSuccess).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
});
it('invokes the optional error hook with the raw backend error payload', async () => {
const onError = vi.fn();
const error = {
status: 409,
code: 'trial_already_used',
message: 'Trial already used',
};
startProTrialMock.mockRejectedValue(error);
await expect(
runStartProTrialAction({
showSuccess,
showError,
navigate,
onError,
}),
).resolves.toBe('error');
expect(onError).toHaveBeenCalledWith(error);
expect(showError).toHaveBeenCalledWith('Trial already used');
});
});
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
getTrialStartErrorKind,
getProTrialStartedMessage,
getTrialAlreadyUsedMessage,
getTrialStartErrorMessage,
normalizeTrialStartError,
getTrialTryAgainLaterMessage,
getUpgradeActionButtonClass,
UPGRADE_ACTION_LABEL,
@@ -28,6 +30,14 @@ describe('upgradePresentation', () => {
expect(getTrialStartErrorMessage('temporary failure')).toBe('temporary failure');
expect(getTrialStartErrorMessage({ code: 'trial_already_used' })).toBe('Trial already used');
expect(getTrialStartErrorMessage({ status: 429 })).toBe('Try again later');
expect(getTrialStartErrorKind({ code: 'trial_already_used' })).toBe('already_used');
expect(getTrialStartErrorKind({ status: 429 })).toBe('retry_later');
expect(getTrialStartErrorKind({ message: 'temporary failure' })).toBe('other');
expect(normalizeTrialStartError({ status: 409, code: 'trial_not_available' })).toEqual({
status: 409,
code: 'trial_not_available',
message: undefined,
});
expect(
getTrialStartErrorMessage({
code: 'trial_not_available',
@@ -5,6 +5,7 @@ export type StartProTrialActionOutcome = 'activated' | 'redirect' | 'error';
export interface RunStartProTrialActionOptions {
branded?: boolean;
onError?: (error: unknown) => void;
successMessage?: string;
showSuccess: (message: string) => void;
showError: (message: string) => void;
@@ -32,6 +33,7 @@ export async function runStartProTrialAction(
showSuccess(successMessage ?? getProTrialStartedMessage());
return 'activated';
} catch (error) {
options.onError?.(error);
showError(getTrialStartErrorMessage(error, { branded }));
return 'error';
}
@@ -20,6 +20,8 @@ export interface TrialStartErrorLike {
message?: string;
}
export type TrialStartErrorKind = 'already_used' | 'retry_later' | 'other';
export function getProTrialStartedMessage(): string {
return 'Pro trial started';
}
@@ -32,7 +34,7 @@ export function getTrialTryAgainLaterMessage(): string {
return 'Try again later';
}
function normalizeTrialStartError(error?: unknown): TrialStartErrorLike | null {
export function normalizeTrialStartError(error?: unknown): TrialStartErrorLike | null {
if (!error) return null;
if (typeof error === 'string') return { message: error };
if (typeof error !== 'object') return null;
@@ -45,15 +47,27 @@ function normalizeTrialStartError(error?: unknown): TrialStartErrorLike | null {
};
}
export function getTrialStartErrorKind(error?: unknown): TrialStartErrorKind {
const normalized = normalizeTrialStartError(error);
if (normalized?.code === 'trial_already_used') {
return 'already_used';
}
if (normalized?.status === 429) {
return 'retry_later';
}
return 'other';
}
export function getTrialStartErrorMessage(
error?: unknown,
options: TrialStartErrorOptions = {},
): string {
const normalized = normalizeTrialStartError(error);
if (normalized?.code === 'trial_already_used') {
const kind = getTrialStartErrorKind(normalized);
if (kind === 'already_used') {
return getTrialAlreadyUsedMessage();
}
if (normalized?.status === 429) {
if (kind === 'retry_later') {
return getTrialTryAgainLaterMessage();
}
if (normalized?.message?.trim()) {