Add the License-panel Pro trial front door (pull-only)

Completes the approved trial flow end to end. The License panel's plan
comparison gains a 'Start 14-day free Pro trial' action (shown only when
unlicensed, trial-eligible, and never-trialed) that rides the existing
purchase-start handoff with trial=1; the handler already forwards
non-reserved params. Pulse Account portal reads the trial arrival flag,
relabels the pro monthly checkout button, shows the card-required note,
and sends trial:true to the checkout session endpoint (server support
deployed in pulse-pro 4c3a28c). Organic portal and public pricing
arrivals see no trial anywhere, per the no-upsell doctrine; retired
local trial-acquisition routes remain 404. Portal dist rebuilt.
This commit is contained in:
rcourtman 2026-07-08 15:29:54 +01:00
parent 34f64e68fc
commit b0792890b0
13 changed files with 229 additions and 22 deletions

View file

@ -79,6 +79,11 @@ interface ProLicensePlanSectionProps {
label: string;
destination: UpgradeDestination;
} | null;
trialAction: {
label: string;
note: string;
destination: UpgradeDestination;
} | null;
};
planSelectionPrompt: ActionNotice | null;
planStatus: {
@ -373,17 +378,34 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
)}
</For>
</div>
<Show when={props.planComparisonSummary.action}>
{(action) => (
<UpgradeButtonLink
variant="outline"
size="settingsActionXs"
class="mt-4 gap-1"
destination={action().destination}
>
{action().label}
</UpgradeButtonLink>
)}
<div class="flex flex-wrap items-center gap-2">
<Show when={props.planComparisonSummary.trialAction}>
{(trialAction) => (
<UpgradeButtonLink
variant="primary"
size="settingsActionXs"
class="mt-4 gap-1"
destination={trialAction().destination}
>
{trialAction().label}
</UpgradeButtonLink>
)}
</Show>
<Show when={props.planComparisonSummary.action}>
{(action) => (
<UpgradeButtonLink
variant="outline"
size="settingsActionXs"
class="mt-4 gap-1"
destination={action().destination}
>
{action().label}
</UpgradeButtonLink>
)}
</Show>
</div>
<Show when={props.planComparisonSummary.trialAction}>
{(trialAction) => <p class="mt-2 text-xs text-muted">{trialAction().note}</p>}
</Show>
</div>
</Show>

View file

@ -0,0 +1,102 @@
import { describe, expect, it, afterEach } from 'vitest';
import { cleanup, render, screen } from '@solidjs/testing-library';
import { Route, Router } from '@solidjs/router';
import type { Component } from 'solid-js';
import { ProLicensePlanSection } from '../ProLicensePlanSection';
import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from '../selfHostedBillingPresentation';
import { resolveUpgradeDestination } from '@/utils/upgradeNavigation';
const baseProps = () => ({
activationSuccessSummary: null,
commercialMigrationNotice: null,
commercialPlanModel: { summary: [], details: [] },
currentPlanSummary: {
title: 'Community',
body: 'Core monitoring',
badgeClass: '',
statusLabel: 'Free',
unlockedFeaturesLabel: 'Included',
unlockedFeatures: [],
includedExtras: [],
supplementalBadges: [],
},
entitlements: null,
grandfatheredPriceNotice: null,
hasLicenseDetails: false,
loading: false,
onReload: () => {},
planSelectionPrompt: null,
planStatus: null,
purchaseActivationNotice: null,
purchaseActivationAction: null,
});
const renderInRouter = (component: Component) => {
render(() => (
<Router>
<Route path="/" component={component} />
</Router>
));
};
describe('ProLicensePlanSection trial action', () => {
afterEach(() => cleanup());
it('renders the trial button with its card-required note and trial link', () => {
renderInRouter(() => (
<ProLicensePlanSection
{...baseProps()}
planComparisonSummary={{
cards: [{ title: 'Pro', body: 'Everything', highlights: [] }],
action: {
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonActionLabel,
destination: resolveUpgradeDestination(
'/auth/license-purchase-start?feature=self_hosted_plan',
),
},
trialAction: {
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionLabel,
note: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionNote,
destination: resolveUpgradeDestination(
'/auth/license-purchase-start?trial=1&feature=self_hosted_plan',
),
},
}}
/>
));
const trialLink = screen.getByRole('link', {
name: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionLabel,
});
expect(trialLink.getAttribute('href')).toContain('trial=1');
expect(trialLink.getAttribute('href')).toContain('feature=self_hosted_plan');
expect(
screen.getByText(SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionNote),
).toBeTruthy();
});
it('renders no trial button when trialAction is null', () => {
renderInRouter(() => (
<ProLicensePlanSection
{...baseProps()}
planComparisonSummary={{
cards: [{ title: 'Pro', body: 'Everything', highlights: [] }],
action: {
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonActionLabel,
destination: resolveUpgradeDestination(
'/auth/license-purchase-start?feature=self_hosted_plan',
),
},
trialAction: null,
}}
/>
));
expect(
screen.queryByText(SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionLabel),
).toBeNull();
expect(
screen.queryByText(SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionNote),
).toBeNull();
});
});

View file

@ -12,6 +12,8 @@ export interface SelfHostedProBillingPresentation {
planSectionDescription: string;
planComparisonSectionTitle: string;
planComparisonActionLabel: string;
planComparisonTrialActionLabel: string;
planComparisonTrialActionNote: string;
usageSectionTitle: string;
hiddenShellTitle: string;
hiddenShellDescription: string;
@ -46,6 +48,9 @@ export const SELF_HOSTED_PRO_BILLING_PRESENTATION: SelfHostedProBillingPresentat
planSectionDescription: 'Current tier and enabled capabilities.',
planComparisonSectionTitle: 'Available plans',
planComparisonActionLabel: 'View plans',
planComparisonTrialActionLabel: 'Start 14-day free Pro trial',
planComparisonTrialActionNote:
'Card required. You will not be charged if you cancel during the trial.',
usageSectionTitle: 'Usage',
hiddenShellTitle: 'Demo mode',
hiddenShellDescription: 'Commercial settings are hidden for this session.',

View file

@ -394,21 +394,43 @@ export function useProLicensePanelState() {
};
});
const planStatus = createMemo(() => getSelfHostedPlanStatusPresentation(entitlements()));
const trialActionEligible = createMemo(() => {
const current = entitlements();
const state = subscriptionState();
if (state === 'active' || state === 'trial') return false;
if (current?.trial_expires_at) return false;
if (current?.trial_eligible === false) return false;
const tier = current?.tier;
return !tier || tier === 'free';
});
const planComparisonSummary = createMemo(() => {
if (!showPlanSelectionPrompt()) {
return { cards: [], action: null };
return { cards: [], action: null, trialAction: null };
}
const comparison = getSelfHostedPlanComparisonPresentation({
entitlements: entitlements(),
});
const showActions =
comparison.cards.length > 0 && purchaseActivationResult().trim().length === 0;
return {
...comparison,
action:
comparison.cards.length > 0 && purchaseActivationResult().trim().length === 0
action: showActions
? {
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonActionLabel,
destination: resolveSelfHostedPurchaseStartDestination(
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
),
}
: null,
trialAction:
showActions && trialActionEligible()
? {
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonActionLabel,
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionLabel,
note: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionNote,
destination: resolveSelfHostedPurchaseStartDestination(
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
new URLSearchParams({ trial: '1' }),
),
}
: null,

View file

@ -1,5 +1,5 @@
{
"source_hash": "ece2119b2e5fc7be4f49d6b42ab4a214497afac1670c5cd14486678ee54b78e8",
"source_hash": "90e5d2ec651ba11669d98c23aa107ea7dc8e07614097c0c655b3e75ded720d17",
"build_inputs": [
"package.json",
"tsconfig.json",

View file

@ -1185,6 +1185,7 @@
return {
openBillingPanelID: "",
upgradeFeatureKey: "",
upgradeTrialRequested: false,
upgradePortalHandoffID: "",
upgradePortalHandoff: createQueryState(null),
upgradePricing: createQueryState(null),
@ -1812,8 +1813,11 @@
return '<article class="billing-upgrade-plan-card' + (plan.highlight ? " highlight" : "") + '">' + (plan.badge ? '<div class="billing-upgrade-plan-badge">' + escapeText(plan.badge) + "</div>" : "") + '<div class="billing-upgrade-plan-header"><div class="billing-upgrade-plan-kicker">' + escapeText(plan.tierKicker) + "</div><h4>" + escapeText(plan.title) + '</h4><div class="billing-upgrade-plan-price">' + escapeText(plan.price) + '</div><div class="billing-upgrade-plan-period">' + escapeText(plan.period) + '</div></div><p class="billing-upgrade-plan-blurb">' + escapeText(plan.blurb) + '</p><ul class="billing-upgrade-plan-features">' + plan.features.map(function(feature) {
return '<li class="billing-upgrade-plan-feature tone-' + escapeAttribute(feature.tone) + '"><span class="billing-upgrade-plan-feature-copy">' + String(feature.html || "") + "</span></li>";
}).join("") + "</ul>" + (plan.note ? '<div class="helper-text">' + escapeText(plan.note) + "</div>" : "") + '<div class="form-actions">' + checkoutButtons.map(function(button) {
return '<button type="button" class="' + escapeAttribute(button.className || "btn-primary") + '" data-account-billing-action="upgrade-start-checkout" data-upgrade-plan-key="' + escapeAttribute(button.planKey || "") + '" data-upgrade-tier="' + escapeAttribute(button.tier || "") + '" data-upgrade-billing-cycle="' + escapeAttribute(button.billingCycle || "") + '"' + (checkoutDisabled ? " disabled" : "") + ">" + escapeText(button.label) + "</button>";
}).join("") + "</div></article>";
var isTrialButton = billingState.upgradeTrialRequested === true && button.tier === "pro" && button.billingCycle === "monthly";
return '<button type="button" class="' + escapeAttribute(button.className || "btn-primary") + '" data-account-billing-action="upgrade-start-checkout" data-upgrade-plan-key="' + escapeAttribute(button.planKey || "") + '" data-upgrade-tier="' + escapeAttribute(button.tier || "") + '" data-upgrade-billing-cycle="' + escapeAttribute(button.billingCycle || "") + '"' + (checkoutDisabled ? " disabled" : "") + ">" + escapeText(isTrialButton ? "Start 14-day free trial" : button.label) + "</button>";
}).join("") + "</div>" + (billingState.upgradeTrialRequested === true && checkoutButtons.some(function(button) {
return button.tier === "pro" && button.billingCycle === "monthly";
}) ? '<div class="helper-text">Card required. You will not be charged if you cancel during the 14-day trial.</div>' : "") + "</article>";
}).join("") + "</div>";
}
function renderUpgradePanel(billingState, _bootstrap) {
@ -2227,11 +2231,13 @@
beginMutationState(nextBillingState.upgradeCheckout);
});
try {
var trialRequested = billingState.upgradeTrialRequested === true && tier === "pro" && billingCycle === "monthly";
var data = await api.postCommercialJSON("/v1/checkout/session", {
plan_key: planKey,
tier,
billing_cycle: billingCycle,
portal_handoff_id: portalHandoffID
portal_handoff_id: portalHandoffID,
...trialRequested ? { trial: true } : {}
});
if (!data || !data.url) {
throw new Error("Checkout URL was not returned.");
@ -3590,6 +3596,10 @@
function normalizeUpgradeFeatureKey2(value) {
return String(value || "").trim();
}
function normalizeUpgradeTrialRequested(value) {
var trimmed = String(value || "").trim().toLowerCase();
return trimmed === "1" || trimmed === "true";
}
function readPortalRuntimeHandoff(locationHref = window.location.href) {
try {
var params = new URL(locationHref).searchParams;
@ -3602,14 +3612,16 @@
email: normalizeHandoffEmail(params.get("email")),
openBillingPanelID,
upgradePortalHandoffID,
upgradeFeatureKey: normalizeUpgradeFeatureKey2(params.get("feature"))
upgradeFeatureKey: normalizeUpgradeFeatureKey2(params.get("feature")),
upgradeTrialRequested: normalizeUpgradeTrialRequested(params.get("trial"))
};
} catch {
return {
email: "",
openBillingPanelID: "",
upgradePortalHandoffID: "",
upgradeFeatureKey: ""
upgradeFeatureKey: "",
upgradeTrialRequested: false
};
}
}
@ -3650,12 +3662,14 @@
billingState.openBillingPanelID = handoff.openBillingPanelID;
billingState.upgradePortalHandoffID = handoff.upgradePortalHandoffID;
billingState.upgradeFeatureKey = handoff.upgradeFeatureKey;
billingState.upgradeTrialRequested = handoff.upgradeTrialRequested;
}, { notify: false });
} else if (handoff.upgradeFeatureKey || handoff.upgradePortalHandoffID) {
store.setActiveShellSection("billing");
store.updateBillingState(function(billingState) {
billingState.upgradePortalHandoffID = handoff.upgradePortalHandoffID;
billingState.upgradeFeatureKey = handoff.upgradeFeatureKey;
billingState.upgradeTrialRequested = handoff.upgradeTrialRequested;
}, { notify: false });
}
return {

View file

@ -152,6 +152,7 @@ describe('portal app', function() {
openBillingPanelID: 'retrieve-billing-panel',
upgradePortalHandoffID: '',
upgradeFeatureKey: '',
upgradeTrialRequested: false,
}
);

View file

@ -248,11 +248,15 @@ export function installBillingRuntime(deps: BillingRuntimeDeps): void {
beginMutationState(nextBillingState.upgradeCheckout);
});
try {
var trialRequested = billingState.upgradeTrialRequested === true &&
tier === 'pro' &&
billingCycle === 'monthly';
var data = await api.postCommercialJSON<PortalCheckoutSessionCreateResponse>('/v1/checkout/session', {
plan_key: planKey,
tier: tier,
billing_cycle: billingCycle,
portal_handoff_id: portalHandoffID,
...(trialRequested ? { trial: true } : {}),
});
if (!data || !data.url) {
throw new Error('Checkout URL was not returned.');

View file

@ -122,6 +122,9 @@ function renderUpgradePlansHTML(billingState: PortalBillingState): string {
}).join('') + '</ul>' +
(plan.note ? '<div class="helper-text">' + escapeText(plan.note) + '</div>' : '') +
'<div class="form-actions">' + checkoutButtons.map(function(button) {
var isTrialButton = billingState.upgradeTrialRequested === true &&
button.tier === 'pro' &&
button.billingCycle === 'monthly';
return (
'<button type="button" class="' + escapeAttribute(button.className || 'btn-primary') + '"' +
' data-account-billing-action="upgrade-start-checkout"' +
@ -130,10 +133,15 @@ function renderUpgradePlansHTML(billingState: PortalBillingState): string {
' data-upgrade-billing-cycle="' + escapeAttribute(button.billingCycle || '') + '"' +
(checkoutDisabled ? ' disabled' : '') +
'>' +
escapeText(button.label) +
escapeText(isTrialButton ? 'Start 14-day free trial' : button.label) +
'</button>'
);
}).join('') + '</div>' +
(billingState.upgradeTrialRequested === true && checkoutButtons.some(function(button) {
return button.tier === 'pro' && button.billingCycle === 'monthly';
})
? '<div class="helper-text">Card required. You will not be charged if you cancel during the 14-day trial.</div>'
: '') +
'</article>'
);
}).join('') + '</div>';

View file

@ -71,6 +71,20 @@ describe('portal runtime', function() {
expect(handoff.openBillingPanelID).toBe('upgrade-billing-panel');
expect(handoff.upgradePortalHandoffID).toBe('cph_signed');
expect(handoff.upgradeFeatureKey).toBe('');
expect(handoff.upgradeTrialRequested).toBe(false);
});
it('reads a trial request from the handoff arrival query', function() {
var handoff = readPortalRuntimeHandoff(
'https://cloud.pulserelay.pro/portal?portal_handoff_id=cph_signed&feature=self_hosted_plan&trial=1',
);
expect(handoff.upgradeTrialRequested).toBe(true);
var noTrial = readPortalRuntimeHandoff(
'https://cloud.pulserelay.pro/portal?portal_handoff_id=cph_signed&trial=definitely',
);
expect(noTrial.upgradeTrialRequested).toBe(false);
});
it('blocks legacy checkout intent arrivals from acting as a trusted upgrade bootstrap', function() {
@ -96,6 +110,7 @@ describe('portal runtime', function() {
openBillingPanelID: 'refund-billing-panel',
upgradePortalHandoffID: '',
upgradeFeatureKey: '',
upgradeTrialRequested: false,
}
);
@ -118,12 +133,14 @@ describe('portal runtime', function() {
openBillingPanelID: 'upgrade-billing-panel',
upgradePortalHandoffID: 'cph_signed',
upgradeFeatureKey: '',
upgradeTrialRequested: true,
}
);
expect(runtime.store.getShellState().activeSection).toBe('billing');
expect(runtime.store.getBillingState().openBillingPanelID).toBe('upgrade-billing-panel');
expect(runtime.store.getBillingState().upgradePortalHandoffID).toBe('cph_signed');
expect(runtime.store.getBillingState().upgradeTrialRequested).toBe(true);
expect(runtime.store.getBillingState().upgradeFeatureKey).toBe('');
});
});

View file

@ -7,6 +7,7 @@ export interface PortalRuntimeHandoff {
openBillingPanelID: string;
upgradePortalHandoffID: string;
upgradeFeatureKey: string;
upgradeTrialRequested: boolean;
}
export interface PortalRuntime {
@ -61,6 +62,11 @@ function normalizeUpgradeFeatureKey(value: string | null): string {
return String(value || '').trim();
}
function normalizeUpgradeTrialRequested(value: string | null): boolean {
var trimmed = String(value || '').trim().toLowerCase();
return trimmed === '1' || trimmed === 'true';
}
export function readPortalRuntimeHandoff(
locationHref: string | undefined = window.location.href,
): PortalRuntimeHandoff {
@ -76,6 +82,7 @@ export function readPortalRuntimeHandoff(
openBillingPanelID: openBillingPanelID,
upgradePortalHandoffID: upgradePortalHandoffID,
upgradeFeatureKey: normalizeUpgradeFeatureKey(params.get('feature')),
upgradeTrialRequested: normalizeUpgradeTrialRequested(params.get('trial')),
};
} catch {
return {
@ -83,6 +90,7 @@ export function readPortalRuntimeHandoff(
openBillingPanelID: '',
upgradePortalHandoffID: '',
upgradeFeatureKey: '',
upgradeTrialRequested: false,
};
}
}
@ -132,12 +140,14 @@ export function createPortalRuntime(
billingState.openBillingPanelID = handoff.openBillingPanelID;
billingState.upgradePortalHandoffID = handoff.upgradePortalHandoffID;
billingState.upgradeFeatureKey = handoff.upgradeFeatureKey;
billingState.upgradeTrialRequested = handoff.upgradeTrialRequested;
}, { notify: false });
} else if (handoff.upgradeFeatureKey || handoff.upgradePortalHandoffID) {
store.setActiveShellSection('billing');
store.updateBillingState(function(billingState) {
billingState.upgradePortalHandoffID = handoff.upgradePortalHandoffID;
billingState.upgradeFeatureKey = handoff.upgradeFeatureKey;
billingState.upgradeTrialRequested = handoff.upgradeTrialRequested;
}, { notify: false });
}
return {

View file

@ -120,6 +120,7 @@ export function createPortalBillingState(): PortalBillingState {
return {
openBillingPanelID: '',
upgradeFeatureKey: '',
upgradeTrialRequested: false,
upgradePortalHandoffID: '',
upgradePortalHandoff: createQueryState(null),
upgradePricing: createQueryState(null),

View file

@ -190,6 +190,7 @@ export interface RefundState {
export interface PortalBillingState {
openBillingPanelID: string;
upgradeFeatureKey: string;
upgradeTrialRequested: boolean;
upgradePortalHandoffID: string;
upgradePortalHandoff: PortalQueryState<PortalUpgradePortalHandoffModel | null>;
upgradePricing: PortalQueryState<PortalUpgradePricingModel | null>;