mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Unify pricing trial CTA handling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user