From a86338f78725b0cbd08ccc81fbb47f44d10992e6 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 25 Mar 2026 10:40:55 +0000 Subject: [PATCH] Unify pricing trial CTA handling --- .../v6/internal/subsystems/cloud-paid.md | 5 ++ frontend-modern/src/pages/PricingV6.tsx | 32 +++++----- .../src/pages/__tests__/PricingV6.test.tsx | 59 +++++++++++++++---- .../utils/__tests__/trialStartAction.test.ts | 22 +++++++ .../__tests__/upgradePresentation.test.ts | 10 ++++ frontend-modern/src/utils/trialStartAction.ts | 2 + .../src/utils/upgradePresentation.ts | 20 ++++++- 7 files changed, 120 insertions(+), 30 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index df2639288..c1ed61b95 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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 diff --git a/frontend-modern/src/pages/PricingV6.tsx b/frontend-modern/src/pages/PricingV6.tsx index 78bc85149..d09a0767f 100644 --- a/frontend-modern/src/pages/PricingV6.tsx +++ b/frontend-modern/src/pages/PricingV6.tsx @@ -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); } diff --git a/frontend-modern/src/pages/__tests__/PricingV6.test.tsx b/frontend-modern/src/pages/__tests__/PricingV6.test.tsx index 2ef5e8c87..48642730c 100644 --- a/frontend-modern/src/pages/__tests__/PricingV6.test.tsx +++ b/frontend-modern/src/pages/__tests__/PricingV6.test.tsx @@ -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 }) =>
{props.children}
, })); @@ -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(() => ); @@ -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(() => ); + + 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', + ); }); }); diff --git a/frontend-modern/src/utils/__tests__/trialStartAction.test.ts b/frontend-modern/src/utils/__tests__/trialStartAction.test.ts index a8dad05e1..63ab8069c 100644 --- a/frontend-modern/src/utils/__tests__/trialStartAction.test.ts +++ b/frontend-modern/src/utils/__tests__/trialStartAction.test.ts @@ -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'); + }); }); diff --git a/frontend-modern/src/utils/__tests__/upgradePresentation.test.ts b/frontend-modern/src/utils/__tests__/upgradePresentation.test.ts index 085aa03fc..5b9104da6 100644 --- a/frontend-modern/src/utils/__tests__/upgradePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/upgradePresentation.test.ts @@ -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', diff --git a/frontend-modern/src/utils/trialStartAction.ts b/frontend-modern/src/utils/trialStartAction.ts index 1af7ce051..3a3f4eeef 100644 --- a/frontend-modern/src/utils/trialStartAction.ts +++ b/frontend-modern/src/utils/trialStartAction.ts @@ -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'; } diff --git a/frontend-modern/src/utils/upgradePresentation.ts b/frontend-modern/src/utils/upgradePresentation.ts index b7cb2db61..dc3493651 100644 --- a/frontend-modern/src/utils/upgradePresentation.ts +++ b/frontend-modern/src/utils/upgradePresentation.ts @@ -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()) {