Make Cloud trial signup copy explicit

This commit is contained in:
rcourtman 2026-04-24 11:42:04 +01:00
parent 3ffdf785f1
commit 6cc4659e75
8 changed files with 65 additions and 33 deletions

View file

@ -234,6 +234,11 @@ Community limit enforcement.
surfaces must describe the real commercial flow as secure checkout ->
Pulse Account -> open workspace, not as an immediate workspace creation or
trial-only shortcut.
Public hosted Cloud trial signup must state the trial duration and
checkout economics before Stripe handoff: Stripe may collect a payment
method, but the subscription starts with the configured trial period and no
upfront charge, then Pulse Account opens the provisioned workspace after
checkout completes.
20. Add contract tests where runtime and pricing need to stay aligned
21. Add or change hosted browser org-context bootstrap through `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, `frontend-modern/src/useAppRuntimeState.ts`, and `frontend-modern/src/utils/apiClient.ts`
That same hosted bootstrap boundary also owns the runtime-capability JSON

View file

@ -69,7 +69,7 @@ function CloudTierCard(props: { tier: CloudPlanDefinition }) {
href={`/cloud/signup?tier=${t.tier}`}
class="w-full inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
>
{`Choose ${t.name}`}
{`Start ${t.name} Trial`}
</A>
</div>
</Card>

View file

@ -20,25 +20,27 @@ describe('CloudPricing', () => {
</Router>
));
expect(await screen.findByRole('link', { name: 'Choose Starter' })).toHaveAttribute(
expect(await screen.findByRole('link', { name: 'Start Starter Trial' })).toHaveAttribute(
'href',
'/cloud/signup?tier=starter',
);
expect(screen.getByText('Founding rate')).toBeInTheDocument();
expect(screen.getByText('$19')).toBeInTheDocument();
expect(screen.getByText('$29/month')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Choose Power' })).toHaveAttribute(
expect(screen.getByRole('link', { name: 'Start Power Trial' })).toHaveAttribute(
'href',
'/cloud/signup?tier=power',
);
expect(screen.getByRole('link', { name: 'Choose Max' })).toHaveAttribute(
expect(screen.getByRole('link', { name: 'Start Max Trial' })).toHaveAttribute(
'href',
'/cloud/signup?tier=max',
);
expect(screen.queryByText('Starter founding rate')).not.toBeInTheDocument();
expect(screen.getAllByText('All Pro features')).toHaveLength(1);
expect(screen.getByText('Managed hosting')).toBeInTheDocument();
expect(screen.getByText('Choose a Cloud plan and complete secure checkout.')).toBeInTheDocument();
expect(
screen.getByText('Choose a Cloud plan and start the 14-day trial in secure checkout.'),
).toBeInTheDocument();
expect(screen.getByText('How it works')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open Pulse Account' })).toHaveAttribute(
'href',

View file

@ -60,10 +60,14 @@ describe('HostedSignup', () => {
));
expect(await screen.findByText('Workspace')).toBeInTheDocument();
expect(screen.getByText('Create your Pulse Cloud account and hosted workspace.')).toBeInTheDocument();
expect(
screen.getByText('Start your 14-day Pulse Cloud trial and hosted workspace.'),
).toBeInTheDocument();
expect(screen.getByText('Plan')).toBeInTheDocument();
expect(screen.getByText('How it works')).toBeInTheDocument();
expect(screen.getByText('Choose a Cloud plan and complete secure checkout.')).toBeInTheDocument();
expect(
screen.getByText('Choose a Cloud plan and start the 14-day trial in secure checkout.'),
).toBeInTheDocument();
expect(screen.getByText('Already signed up?')).toBeInTheDocument();
expect(screen.getByText('Request a fresh Pulse Account sign-in link.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Email Pulse Account Link' })).toBeInTheDocument();
@ -74,7 +78,7 @@ describe('HostedSignup', () => {
fireEvent.input(screen.getByLabelText('Organization Name'), {
target: { value: 'Pulse Labs' },
});
fireEvent.submit(screen.getByRole('button', { name: 'Continue to Checkout' }).closest('form')!);
fireEvent.submit(screen.getByRole('button', { name: 'Start Trial in Checkout' }).closest('form')!);
await waitFor(() => {
expect(signupMock).toHaveBeenCalledWith({

View file

@ -32,7 +32,7 @@ describe('cloudPlans', () => {
it('keeps shared cloud commercial copy in the common contract', () => {
expect(CLOUD_COMMERCIAL_PRESENTATION).toEqual({
pageTitle: 'Pulse Cloud',
pageDescription: 'Managed Pulse hosting with Pro features included.',
pageDescription: 'Managed Pulse hosting with Pro features included. Start with a 14-day trial.',
includedInAllHeading: 'Included in every Cloud plan',
includedInAllItems: [
'All Pro features',
@ -50,15 +50,15 @@ describe('cloudPlans', () => {
it('keeps hosted signup commercial copy in the common contract', () => {
expect(HOSTED_SIGNUP_PRESENTATION).toEqual({
pageTitlePrefix: 'Pulse Cloud',
pageDescription: 'Create your Pulse Cloud account and hosted workspace.',
pageDescription: 'Start your 14-day Pulse Cloud trial and hosted workspace.',
workspaceHeading: 'Workspace',
planHeading: 'Plan',
nextHeading: 'How it works',
nextSteps: CLOUD_ACCOUNT_FLOW_STEPS,
existingAccountHeading: 'Already signed up?',
existingAccountDescription: 'Request a fresh Pulse Account sign-in link.',
createWorkspaceLabel: 'Continue to Checkout',
creatingWorkspaceLabel: 'Preparing Checkout...',
createWorkspaceLabel: 'Start Trial in Checkout',
creatingWorkspaceLabel: 'Preparing Trial Checkout...',
emailSignInLinkLabel: 'Email Pulse Account Link',
sendingSignInLinkLabel: 'Sending...',
});

View file

@ -46,7 +46,7 @@ export interface HostedSignupPresentation {
}
export const CLOUD_ACCOUNT_FLOW_STEPS = [
'Choose a Cloud plan and complete secure checkout.',
'Choose a Cloud plan and start the 14-day trial in secure checkout.',
'Use the email link to open Pulse Account.',
'Open your workspace and connect systems.',
] as const;
@ -104,7 +104,7 @@ export const CLOUD_PLAN_LABELS: Record<string, string> = {
export const CLOUD_COMMERCIAL_PRESENTATION: CloudCommercialPresentation = {
pageTitle: 'Pulse Cloud',
pageDescription: 'Managed Pulse hosting with Pro features included.',
pageDescription: 'Managed Pulse hosting with Pro features included. Start with a 14-day trial.',
includedInAllHeading: 'Included in every Cloud plan',
includedInAllItems: [
'All Pro features',
@ -120,15 +120,15 @@ export const CLOUD_COMMERCIAL_PRESENTATION: CloudCommercialPresentation = {
export const HOSTED_SIGNUP_PRESENTATION: HostedSignupPresentation = {
pageTitlePrefix: 'Pulse Cloud',
pageDescription: 'Create your Pulse Cloud account and hosted workspace.',
pageDescription: 'Start your 14-day Pulse Cloud trial and hosted workspace.',
workspaceHeading: 'Workspace',
planHeading: 'Plan',
nextHeading: 'How it works',
nextSteps: CLOUD_ACCOUNT_FLOW_STEPS,
existingAccountHeading: 'Already signed up?',
existingAccountDescription: 'Request a fresh Pulse Account sign-in link.',
createWorkspaceLabel: 'Continue to Checkout',
creatingWorkspaceLabel: 'Preparing Checkout...',
createWorkspaceLabel: 'Start Trial in Checkout',
creatingWorkspaceLabel: 'Preparing Trial Checkout...',
emailSignInLinkLabel: 'Email Pulse Account Link',
sendingSignInLinkLabel: 'Sending...',
} as const;

View file

@ -58,7 +58,7 @@ var publicCloudSignupPageTemplate = template.Must(template.New("public-cloud-sig
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Start Pulse Cloud</title>
<title>Start Pulse Cloud Trial</title>
<style nonce="{{.Nonce}}">
:root { color-scheme: light; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: linear-gradient(140deg, #f8fafc, #e2e8f0); color: #0f172a; }
@ -83,8 +83,8 @@ var publicCloudSignupPageTemplate = template.Must(template.New("public-cloud-sig
<body>
<div class="wrap">
<div class="card">
<h1>Start Pulse Cloud</h1>
<p>Create your hosted Pulse workspace. Checkout is secure and provisioning starts automatically after payment confirmation.</p>
<h1>Start your {{.TrialDays}}-day Pulse Cloud trial</h1>
<p>Create your hosted Pulse workspace. Stripe checkout securely collects a payment method, but the Cloud subscription starts with a {{.TrialDays}}-day trial and no upfront charge. Provisioning starts after checkout confirms the subscription.</p>
{{if .ErrorMessage}}<div class="error">{{.ErrorMessage}}</div>{{end}}
{{if .Cancelled}}<div class="note">Checkout was cancelled. You can start again below.</div>{{end}}
@ -94,9 +94,9 @@ var publicCloudSignupPageTemplate = template.Must(template.New("public-cloud-sig
{{if or .HasPower .HasMax}}
<label>Plan</label>
<div class="tier-group">
<label class="tier-option"><input type="radio" name="tier" value="starter" {{if eq .Tier "starter"}}checked{{end}}> <strong>Starter</strong> 10 hosts, from $29/mo</label>
{{if .HasPower}}<label class="tier-option"><input type="radio" name="tier" value="power" {{if eq .Tier "power"}}checked{{end}}> <strong>Power</strong> 30 hosts, from $49/mo</label>{{end}}
{{if .HasMax}}<label class="tier-option"><input type="radio" name="tier" value="max" {{if eq .Tier "max"}}checked{{end}}> <strong>Max</strong> 75 hosts, from $79/mo</label>{{end}}
<label class="tier-option"><input type="radio" name="tier" value="starter" {{if eq .Tier "starter"}}checked{{end}}> <strong>Starter</strong> 10 monitored systems, $29/mo after trial</label>
{{if .HasPower}}<label class="tier-option"><input type="radio" name="tier" value="power" {{if eq .Tier "power"}}checked{{end}}> <strong>Power</strong> 30 monitored systems, $49/mo after trial</label>{{end}}
{{if .HasMax}}<label class="tier-option"><input type="radio" name="tier" value="max" {{if eq .Tier "max"}}checked{{end}}> <strong>Max</strong> 75 monitored systems, $79/mo after trial</label>{{end}}
</div>
{{else}}
<input type="hidden" name="tier" value="starter">
@ -113,7 +113,7 @@ var publicCloudSignupPageTemplate = template.Must(template.New("public-cloud-sig
<p class="fine">After checkout, we will email a Pulse Account sign-in link so you can open your hosted workspace.</p>
<ol>
<li>Your Stripe checkout completes securely.</li>
<li>Stripe starts your {{.TrialDays}}-day Cloud trial securely.</li>
<li>Pulse Cloud provisions your hosted workspace.</li>
<li>The email link opens Pulse Account, where you open your workspace and continue setup.</li>
</ol>
@ -128,7 +128,7 @@ var publicCloudSignupCompleteTemplate = template.Must(template.New("public-cloud
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pulse Cloud Checkout Complete</title>
<title>Pulse Cloud Trial Checkout Complete</title>
<style nonce="{{.Nonce}}">
:root { color-scheme: light; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f8fafc; color: #0f172a; }
@ -141,8 +141,8 @@ var publicCloudSignupCompleteTemplate = template.Must(template.New("public-cloud
<body>
<div class="wrap">
<div class="card">
<h1>Checkout Complete</h1>
<p>Your checkout completed. Pulse Cloud is provisioning your hosted workspace.</p>
<h1>Trial checkout complete</h1>
<p>Your {{.TrialDays}}-day Pulse Cloud trial checkout completed. Pulse Cloud is provisioning your hosted workspace.</p>
<p>Watch your inbox for a Pulse Account sign-in link. That link lands in Pulse Account, where you can open your workspace and continue setup.</p>
</div>
</div>
@ -172,11 +172,13 @@ type publicCloudSignupPageData struct {
Nonce string
HasPower bool // true if Cloud Power price is configured
HasMax bool // true if Cloud Max price is configured
TrialDays int
}
// publicCloudSignupCompleteData carries nonce for the signup-complete page.
type publicCloudSignupCompleteData struct {
Nonce string
Nonce string
TrialDays int
}
type publicCloudSignupRequest struct {
@ -269,6 +271,7 @@ func (h *PublicCloudSignupHandlers) HandleSignupPage(w http.ResponseWriter, r *h
Cancelled: strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("cancelled")), "1"),
HasPower: h.cfg != nil && strings.TrimSpace(h.cfg.CloudPowerPriceID) != "",
HasMax: h.cfg != nil && strings.TrimSpace(h.cfg.CloudMaxPriceID) != "",
TrialDays: trialSignupTrialDays,
}
h.renderSignupPage(w, r, http.StatusOK, data)
case http.MethodPost:
@ -290,6 +293,7 @@ func (h *PublicCloudSignupHandlers) HandleSignupPage(w http.ResponseWriter, r *h
ErrorMessage: "Invalid plan tier selected.",
HasPower: h.cfg != nil && strings.TrimSpace(h.cfg.CloudPowerPriceID) != "",
HasMax: h.cfg != nil && strings.TrimSpace(h.cfg.CloudMaxPriceID) != "",
TrialDays: trialSignupTrialDays,
})
return
}
@ -303,6 +307,7 @@ func (h *PublicCloudSignupHandlers) HandleSignupPage(w http.ResponseWriter, r *h
ErrorMessage: "A valid email address is required.",
HasPower: h.cfg != nil && strings.TrimSpace(h.cfg.CloudPowerPriceID) != "",
HasMax: h.cfg != nil && strings.TrimSpace(h.cfg.CloudMaxPriceID) != "",
TrialDays: trialSignupTrialDays,
})
return
}
@ -315,6 +320,7 @@ func (h *PublicCloudSignupHandlers) HandleSignupPage(w http.ResponseWriter, r *h
ErrorMessage: "Organization name must be 3-64 characters and cannot contain slashes.",
HasPower: h.cfg != nil && strings.TrimSpace(h.cfg.CloudPowerPriceID) != "",
HasMax: h.cfg != nil && strings.TrimSpace(h.cfg.CloudMaxPriceID) != "",
TrialDays: trialSignupTrialDays,
})
return
}
@ -327,6 +333,7 @@ func (h *PublicCloudSignupHandlers) HandleSignupPage(w http.ResponseWriter, r *h
ErrorMessage: "The selected plan tier is not currently available.",
HasPower: h.cfg != nil && strings.TrimSpace(h.cfg.CloudPowerPriceID) != "",
HasMax: h.cfg != nil && strings.TrimSpace(h.cfg.CloudMaxPriceID) != "",
TrialDays: trialSignupTrialDays,
})
return
}
@ -342,6 +349,7 @@ func (h *PublicCloudSignupHandlers) HandleSignupPage(w http.ResponseWriter, r *h
ErrorMessage: "Unable to create checkout session. Please try again.",
HasPower: h.cfg != nil && strings.TrimSpace(h.cfg.CloudPowerPriceID) != "",
HasMax: h.cfg != nil && strings.TrimSpace(h.cfg.CloudMaxPriceID) != "",
TrialDays: trialSignupTrialDays,
})
return
}
@ -358,7 +366,8 @@ func (h *PublicCloudSignupHandlers) HandleSignupComplete(w http.ResponseWriter,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := publicCloudSignupCompleteTemplate.Execute(w, publicCloudSignupCompleteData{
Nonce: cpsec.NonceFromContext(r.Context()),
Nonce: cpsec.NonceFromContext(r.Context()),
TrialDays: trialSignupTrialDays,
}); err != nil {
log.Error().Err(err).Msg("public cloud signup complete page render failed")
}
@ -407,7 +416,7 @@ func (h *PublicCloudSignupHandlers) HandlePublicSignup(w http.ResponseWriter, r
writePublicSignupJSON(w, http.StatusCreated, map[string]any{
"checkout_url": checkoutURL,
"message": "Checkout session created. Continue in Stripe to provision your Pulse Cloud workspace.",
"message": fmt.Sprintf("Checkout session created. Continue in Stripe to start your %d-day Pulse Cloud trial and provision your workspace.", trialSignupTrialDays),
})
}
@ -553,6 +562,9 @@ func (h *PublicCloudSignupHandlers) buildCheckoutMetadata(priceID, orgName strin
func (h *PublicCloudSignupHandlers) renderSignupPage(w http.ResponseWriter, r *http.Request, status int, data publicCloudSignupPageData) {
data.Nonce = cpsec.NonceFromContext(r.Context())
if data.TrialDays <= 0 {
data.TrialDays = trialSignupTrialDays
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := publicCloudSignupPageTemplate.Execute(w, data); err != nil {

View file

@ -75,9 +75,12 @@ func TestPublicCloudSignupHandleSignupPageRendersForm(t *testing.T) {
t.Fatalf("status=%d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
if !strings.Contains(body, "<form") || !strings.Contains(body, "Start Pulse Cloud") {
if !strings.Contains(body, "<form") || !strings.Contains(body, "Start your 14-day Pulse Cloud trial") {
t.Fatalf("expected signup form markup in response body")
}
if !strings.Contains(body, "no upfront charge") {
t.Fatalf("expected trial/no-upfront-charge copy in response body")
}
if !strings.Contains(body, `action="/cloud/signup"`) {
t.Fatalf("expected signup form to post to the canonical cloud signup path")
}
@ -94,8 +97,11 @@ func TestPublicCloudSignupHandleSignupCompleteRendersPulseAccountHandoff(t *test
t.Fatalf("status=%d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
if !strings.Contains(body, "Checkout Complete") {
t.Fatalf("expected checkout completion heading")
if !strings.Contains(body, "Trial checkout complete") {
t.Fatalf("expected trial checkout completion heading")
}
if !strings.Contains(body, "14-day Pulse Cloud trial") {
t.Fatalf("expected trial duration in completion copy")
}
if !strings.Contains(body, "Pulse Account sign-in link") {
t.Fatalf("expected Pulse Account handoff copy")
@ -204,6 +210,9 @@ func TestPublicCloudSignupHandlePublicSignupCreatesCheckout(t *testing.T) {
if got := strings.TrimSpace(asString(payload["checkout_url"])); got != "https://checkout.stripe.com/c/pay/cs_live" {
t.Fatalf("checkout_url=%q, want stripe URL", got)
}
if got := strings.TrimSpace(asString(payload["message"])); !strings.Contains(got, "14-day Pulse Cloud trial") {
t.Fatalf("message=%q, want trial copy", got)
}
}
func TestPublicCloudSignupHandlePublicMagicLinkRequestAlwaysOpaqueWhenUnknown(t *testing.T) {