Move activation copy into shared presentation

This commit is contained in:
rcourtman 2026-03-26 23:23:29 +00:00
parent 70102bf542
commit 5c13496797
6 changed files with 116 additions and 21 deletions

View file

@ -233,6 +233,11 @@ That same helper boundary also owns generic settings-paywall CTA labels such as
`AIRuntimeControlsSection.tsx` and `RelaySettingsPanel.tsx` must consume the
shared upgrade presentation owner instead of embedding those CTA strings
locally.
The same shared self-hosted commercial presentation boundary also owns the
activation-surface copy for `SelfHostedCommercialActivationSection.tsx`,
including the activation field label/help text, legacy-key exchange notice,
and self-serve trial panel labels, so that activation wording does not drift
separately from the rest of the Pro billing surface.
Hosted self-serve trial leases are also part of that same contract. A redeemed
hosted trial must carry the canonical Pro capability set and the authoritative
`limits.max_monitored_systems` cap inside the signed lease rather than relying

View file

@ -1,5 +1,6 @@
import { Component, Show } from 'solid-js';
import { formField, formHelpText, labelClass, controlClass } from '@/components/shared/Form';
import { SELF_HOSTED_ACTIVATION_PRESENTATION } from '@/utils/licensePresentation';
import { CommercialSection } from './CommercialBillingSections';
interface SelfHostedCommercialActivationSectionProps {
@ -21,42 +22,37 @@ export const SelfHostedCommercialActivationSection: Component<
SelfHostedCommercialActivationSectionProps
> = (props) => (
<CommercialSection
title="Activation"
description="Activate, clear, or start a trial for the self-hosted Pulse Pro entitlement that controls paid features on this instance."
title={SELF_HOSTED_ACTIVATION_PRESENTATION.sectionTitle}
description={SELF_HOSTED_ACTIVATION_PRESENTATION.sectionDescription}
>
<div class={formField}>
<label class={labelClass()} for="pulse-pro-license-key">
License / Activation Key
{SELF_HOSTED_ACTIVATION_PRESENTATION.fieldLabel}
</label>
<textarea
id="pulse-pro-license-key"
class={controlClass('min-h-[120px] font-mono')}
placeholder="Paste your license key or activation key"
placeholder={SELF_HOSTED_ACTIVATION_PRESENTATION.fieldPlaceholder}
value={props.licenseKey}
onInput={(event) => props.onLicenseKeyInput(event.currentTarget.value)}
/>
<p class={formHelpText}>
Paste the Pulse v6 activation key shown on the hosted checkout success page. A backup copy
is also sent by email, but the hosted success page is the primary handoff. You can also
paste a legacy Pulse v5 Pro/Lifetime license key and Pulse will exchange it automatically
during activation when migration is available. By activating a license, you agree to the{' '}
{SELF_HOSTED_ACTIVATION_PRESENTATION.helpTextBeforeTerms}{' '}
<a
href="https://github.com/rcourtman/Pulse/blob/main/TERMS.md"
target="_blank"
rel="noopener noreferrer"
class="inline-flex min-h-10 sm:min-h-9 items-center rounded px-1 text-blue-600 hover:underline"
>
Terms of Service
{SELF_HOSTED_ACTIVATION_PRESENTATION.termsLabel}
</a>
.
{SELF_HOSTED_ACTIVATION_PRESENTATION.helpTextAfterTerms}
</p>
<Show when={props.looksLikeLegacyLicenseKey}>
<div class="mt-3 rounded-md border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900 p-3 text-sm text-amber-800 dark:text-amber-200">
<p class="font-medium">Legacy v5 license detected</p>
<p class="font-medium">{SELF_HOSTED_ACTIVATION_PRESENTATION.legacyNotice.title}</p>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
Pulse will try to exchange this key into the v6 activation model automatically. If the
exchange cannot complete immediately, retry from this panel or use the self-serve
retrieval flow to get the current v6 activation key.
{SELF_HOSTED_ACTIVATION_PRESENTATION.legacyNotice.body}
</p>
</div>
</Show>
@ -68,28 +64,36 @@ export const SelfHostedCommercialActivationSection: Component<
onClick={props.onActivate}
disabled={props.activating || !props.licenseKey.trim()}
>
{props.activating ? 'Activating...' : 'Activate License'}
{props.activating
? SELF_HOSTED_ACTIVATION_PRESENTATION.activatePendingLabel
: SELF_HOSTED_ACTIVATION_PRESENTATION.activateIdleLabel}
</button>
<button
class="min-h-10 sm:min-h-9 px-4 py-2.5 text-sm font-medium rounded-md border border-border text-base-content hover:bg-surface-hover transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
onClick={props.onClear}
disabled={props.clearing || props.loading || !props.hasLicenseDetails}
>
{props.clearing ? 'Clearing...' : 'Clear License'}
{props.clearing
? SELF_HOSTED_ACTIVATION_PRESENTATION.clearPendingLabel
: SELF_HOSTED_ACTIVATION_PRESENTATION.clearIdleLabel}
</button>
</div>
<Show when={props.showTrialStart}>
<div class="rounded-md border border-border bg-surface-alt p-3">
<p class="text-sm font-medium text-base-content">Try Pro for free</p>
<p class="text-xs text-muted mt-1">Start a 14-day Pro trial for this organization.</p>
<p class="text-sm font-medium text-base-content">
{SELF_HOSTED_ACTIVATION_PRESENTATION.trial.title}
</p>
<p class="text-xs text-muted mt-1">{SELF_HOSTED_ACTIVATION_PRESENTATION.trial.body}</p>
<button
type="button"
class="mt-3 inline-flex min-h-10 sm:min-h-9 items-center justify-center px-4 py-2.5 text-sm font-medium rounded-md bg-emerald-600 text-white hover:bg-emerald-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
disabled={props.startingTrial}
onClick={props.onStartTrial}
>
{props.startingTrial ? 'Starting...' : 'Start 14-day Pro Trial'}
{props.startingTrial
? SELF_HOSTED_ACTIVATION_PRESENTATION.trial.pendingActionLabel
: SELF_HOSTED_ACTIVATION_PRESENTATION.trial.idleActionLabel}
</button>
</div>
</Show>

View file

@ -6,6 +6,7 @@ import { ProLicensePanel } from '../ProLicensePanel';
import proLicensePanelSource from '../ProLicensePanel.tsx?raw';
import proLicensePanelStateSource from '../useProLicensePanelState.ts?raw';
import proLicensePlanSectionSource from '../ProLicensePlanSection.tsx?raw';
import selfHostedCommercialActivationSectionSource from '../SelfHostedCommercialActivationSection.tsx?raw';
let mockEntitlements: LicenseEntitlements | null = null;
@ -470,5 +471,12 @@ describe('ProLicensePanel', () => {
expect(proLicensePlanSectionSource).toContain('getInactiveProUpsellNotice');
expect(proLicensePlanSectionSource).not.toContain('Your Pro trial has ended');
expect(proLicensePlanSectionSource).not.toContain('Unlock Pulse Patrol, alert analysis, auto-fix, and more.');
expect(selfHostedCommercialActivationSectionSource).toContain(
'SELF_HOSTED_ACTIVATION_PRESENTATION',
);
expect(selfHostedCommercialActivationSectionSource).not.toContain('Start 14-day Pro Trial');
expect(selfHostedCommercialActivationSectionSource).not.toContain(
'Legacy v5 license detected',
);
});
});

View file

@ -551,8 +551,18 @@ describe('Settings architecture guardrails', () => {
expect(monitoredSystemPresentationSource).toContain(
'export function getMonitoredSystemDisclosureToggleLabel',
);
expect(selfHostedCommercialActivationSectionSource).toContain('License / Activation Key');
expect(selfHostedCommercialActivationSectionSource).toContain('Start 14-day Pro Trial');
expect(selfHostedCommercialActivationSectionSource).toContain(
'@/utils/licensePresentation',
);
expect(selfHostedCommercialActivationSectionSource).toContain(
'SELF_HOSTED_ACTIVATION_PRESENTATION',
);
expect(selfHostedCommercialActivationSectionSource).not.toContain(
'License / Activation Key',
);
expect(selfHostedCommercialActivationSectionSource).not.toContain(
'Start 14-day Pro Trial',
);
expect(organizationBillingPanelSource).toContain('./CommercialBillingSections');
expect(organizationBillingPanelSource).toContain('./OrganizationBillingLoadingState');
expect(organizationBillingPanelSource).toContain('./useOrganizationBillingPanelState');

View file

@ -18,6 +18,7 @@ import {
getTrialEndedProLicenseNotice,
getTrialActivationNotice,
isDisplayableLicenseFeature,
SELF_HOSTED_ACTIVATION_PRESENTATION,
} from '@/utils/licensePresentation';
describe('licensePresentation', () => {
@ -47,6 +48,19 @@ describe('licensePresentation', () => {
body: 'Unlock Pulse Patrol, alert analysis, auto-fix, and more.',
actionLabel: 'View Pro plans',
});
expect(SELF_HOSTED_ACTIVATION_PRESENTATION).toMatchObject({
sectionTitle: 'Activation',
fieldLabel: 'License / Activation Key',
activateIdleLabel: 'Activate License',
clearIdleLabel: 'Clear License',
legacyNotice: {
title: 'Legacy v5 license detected',
},
trial: {
title: 'Try Pro for free',
idleActionLabel: 'Start 14-day Pro Trial',
},
});
});
it('returns canonical feature labels', () => {

View file

@ -74,6 +74,34 @@ export interface BillingAdminOrganizationBadge {
badgeClass: string;
}
export interface SelfHostedActivationNoticeCopy {
title: string;
body: string;
}
export interface SelfHostedActivationTrialCopy {
title: string;
body: string;
idleActionLabel: string;
pendingActionLabel: string;
}
export interface SelfHostedActivationPresentation {
sectionTitle: string;
sectionDescription: string;
fieldLabel: string;
fieldPlaceholder: string;
helpTextBeforeTerms: string;
helpTextAfterTerms: string;
termsLabel: string;
activateIdleLabel: string;
activatePendingLabel: string;
clearIdleLabel: string;
clearPendingLabel: string;
legacyNotice: SelfHostedActivationNoticeCopy;
trial: SelfHostedActivationTrialCopy;
}
const GRANDFATHERED_V5_PLAN_LABELS: Record<string, string> = {
v5_lifetime_grandfathered: 'V5 Lifetime Grandfathered',
v5_pro_monthly_grandfathered: 'V5 Pro Monthly (Grandfathered)',
@ -308,6 +336,32 @@ export const getInactiveProUpsellNotice = (): LicenseActionNotice => ({
actionLabel: 'View Pro plans',
});
export const SELF_HOSTED_ACTIVATION_PRESENTATION: SelfHostedActivationPresentation = {
sectionTitle: 'Activation',
sectionDescription:
'Activate, clear, or start a trial for the self-hosted Pulse Pro entitlement that controls paid features on this instance.',
fieldLabel: 'License / Activation Key',
fieldPlaceholder: 'Paste your license key or activation key',
helpTextBeforeTerms:
'Paste the Pulse v6 activation key shown on the hosted checkout success page. A backup copy is also sent by email, but the hosted success page is the primary handoff. You can also paste a legacy Pulse v5 Pro/Lifetime license key and Pulse will exchange it automatically during activation when migration is available. By activating a license, you agree to the',
helpTextAfterTerms: '.',
termsLabel: 'Terms of Service',
activateIdleLabel: 'Activate License',
activatePendingLabel: 'Activating...',
clearIdleLabel: 'Clear License',
clearPendingLabel: 'Clearing...',
legacyNotice: {
title: 'Legacy v5 license detected',
body: 'Pulse will try to exchange this key into the v6 activation model automatically. If the exchange cannot complete immediately, retry from this panel or use the self-serve retrieval flow to get the current v6 activation key.',
},
trial: {
title: 'Try Pro for free',
body: 'Start a 14-day Pro trial for this organization.',
idleActionLabel: 'Start 14-day Pro Trial',
pendingActionLabel: 'Starting...',
},
};
export const getOrganizationBillingLicenseStatusLabel = (
status?: Pick<LicenseStatus, 'valid' | 'in_grace_period'> | null,
): string => {